Repository: lablup/backend.ai-webui
Pull request #3626: refactor(FR-967): migrate list components in resources page to Neo Style
Author: login: ironAiken2
Labels: name: size:L
description: 100~500 LoC
Created: 2025-05-09T02:27:59Z | Merged: 2025-05-20T07:11:26Z

### Refactor Resource Page to Neo UI
resolves #3462 (FR-967)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lSyr8xXz1wdXALkJKzVx/ebf7f27f-4b5d-42b7-9522-6bff5c1791ef.png)

> The controls and generate functions are worked on separate stacks.

This PR updates the Resource Page UI by:

1. Replacing standard Ant Design Table with BAITable component in AgentList and StorageProxyList
2. Adding a new ResourceGroupList component using BAITable instead of the web component
3. Improving layout consistency across all resource list components
4. Fixing table styling with proper padding and spacing
5. Moving control buttons to the right side of tables with fixed positioning
6. Standardizing pagination display format across tables

**Checklist:** (if applicable)

- [ ] Documentation
- [ ] Minium required manager version
- [ ] Specific setting for review (eg., KB link, endpoint or how to setup)
- [ ] Minimum requirements to check during review
- [ ] Test case(s) to demonstrate the difference of before/after

Linked issue #3462: NEO Resource group page for admin



JIRA Issue: FR-774

Diff:
diff --git a/react/src/components/AgentList.tsx b/react/src/components/AgentList.tsx
index e7a6b4cdc1..37c75ebc97 100644
--- a/react/src/components/AgentList.tsx
+++ b/react/src/components/AgentList.tsx
@@ -47,14 +47,10 @@ import { useLazyLoadQuery } from 'react-relay';
 type Agent = NonNullable<AgentListQuery$data['agent_list']>['items'][number];
 
 interface AgentListProps {
-  containerStyle?: React.CSSProperties;
   tableProps?: Omit<TableProps, 'dataSource'>;
 }
 
-const AgentList: React.FC<AgentListProps> = ({
-  containerStyle,
-  tableProps,
-}) => {
+const AgentList: React.FC<AgentListProps> = ({ tableProps }) => {
   const { t } = useTranslation();
   const { token } = theme.useToken();
   const { isDarkMode } = useThemeMode();
@@ -742,8 +738,8 @@ const AgentList: React.FC<AgentListProps> = ({
     useHiddenColumnKeysSetting('AgentList');
 
   return (
-    <Flex direction="column" align="stretch" style={containerStyle} gap={'sm'}>
-      <Flex justify="between" align="start" gap="xs" wrap="wrap">
+    <Flex direction="column" align="stretch" gap="sm">
+      <Flex justify="between" align="start" wrap="wrap">
         <Flex
           direction="row"
           gap={'sm'}
diff --git a/react/src/components/ResourceGroupList.tsx b/react/src/components/ResourceGroupList.tsx
new file mode 100644
index 0000000000..37b6610c62
--- /dev/null
+++ b/react/src/components/ResourceGroupList.tsx
@@ -0,0 +1,188 @@
+import { filterEmptyItem, filterNonNullItems } from '../helper';
+import { useUpdatableState } from '../hooks';
+import BAIFetchKeyButton from './BAIFetchKeyButton';
+import BAIRadioGroup from './BAIRadioGroup';
+import BAITable from './BAITable';
+import Flex from './Flex';
+import {
+  ResourceGroupListQuery,
+  ResourceGroupListQuery$data,
+} from './__generated__/ResourceGroupListQuery.graphql';
+import {
+  CheckOutlined,
+  CloseOutlined,
+  DeleteOutlined,
+  InfoCircleOutlined,
+  SettingOutlined,
+} from '@ant-design/icons';
+import { Button, theme } from 'antd';
+import { ColumnsType } from 'antd/es/table';
+import graphql from 'babel-plugin-relay/macro';
+import _ from 'lodash';
+import { useState, useTransition } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useLazyLoadQuery } from 'react-relay';
+
+type ResourceGroup = NonNullable<
+  NonNullable<
+    NonNullable<ResourceGroupListQuery$data>['scaling_groups']
+  >[number]
+>;
+
+const ResourceGroupList: React.FC = () => {
+  const { t } = useTranslation();
+  const { token } = theme.useToken();
+  const [activeType, setActiveType] = useState<'active' | 'inactive'>('active');
+  const [fetchKey, updateFetchKey] = useUpdatableState('first');
+  const [isActiveTypePending, startActiveTypeTransition] = useTransition();
+  const [isPendingRefetch, startRefetchTransition] = useTransition();
+
+  const { scaling_groups } = useLazyLoadQuery<ResourceGroupListQuery>(
+    graphql`
+      query ResourceGroupListQuery($is_active: Boolean) {
+        scaling_groups(is_active: $is_active) {
+          name
+          description
+          is_public
+          driver
+          scheduler
+          wsproxy_addr
+        }
+      }
+    `,
+    {
+      is_active: activeType === 'active',
+    },
+    {
+      fetchPolicy: 'store-and-network',
+      fetchKey,
+    },
+  );
+
+  const columns: ColumnsType<ResourceGroup> = filterEmptyItem([
+    {
+      key: 'name',
+      title: t('resourceGroup.Name'),
+      dataIndex: 'name',
+    },
+    {
+      key: 'description',
+      title: t('resourceGroup.Description'),
+      dataIndex: 'description',
+      render: (value) => value || '-',
+    },
+    {
+      key: 'is_public',
+      title: t('resourceGroup.Public'),
+      dataIndex: 'is_public',
+      render: (value) => {
+        return value ? (
+          <CheckOutlined style={{ color: token.colorSuccess }} />
+        ) : (
+          <CloseOutlined style={{ color: token.colorTextSecondary }} />
+        );
+      },
+    },
+    {
+      key: 'driver',
+      title: t('resourceGroup.Driver'),
+      dataIndex: 'driver',
+    },
+    {
+      key: 'scheduler',
+      title: t('resourceGroup.Scheduler'),
+      dataIndex: 'scheduler',
+      render: (value) => _.toUpper(value),
+    },
+    {
+      key: 'wsproxy_addr',
+      title: t('resourceGroup.WsproxyAddress'),
+      dataIndex: 'wsproxy_addr',
+      render: (value) => value || '-',
+    },
+    {
+      key: 'controls',
+      title: t('general.Control'),
+      fixed: 'right',
+      render: () => {
+        return (
+          <Flex>
+            <Button
+              type="text"
+              size="large"
+              icon={<InfoCircleOutlined />}
+              style={{ color: token.colorSuccess }}
+            />
+            <Button
+              type="text"
+              size="large"
+              icon={<SettingOutlined />}
+              style={{
+                color: token.colorInfo,
+              }}
+            />
+            <Button
+              type="text"
+              size="large"
+              icon={
+                <DeleteOutlined
+                  style={{
+                    color: token.colorError,
+                  }}
+                />
+              }
+            />
+          </Flex>
+        );
+      },
+    },
+  ]);
+
+  return (
+    <Flex direction="column" align="stretch" gap="sm">
+      <Flex justify="between">
+        <BAIRadioGroup
+          value={activeType}
+          onChange={(value) => {
+            startActiveTypeTransition(() => {
+              setActiveType(value.target.value);
+            });
+          }}
+          optionType="button"
+          options={[
+            {
+              label: 'Active',
+              value: 'active',
+            },
+            {
+              label: 'Inactive',
+              value: 'inactive',
+            },
+          ]}
+        />
+        <BAIFetchKeyButton
+          loading={isPendingRefetch}
+          value={fetchKey}
+          onChange={() => {
+            startRefetchTransition(() => {
+              updateFetchKey();
+            });
+          }}
+        />
+      </Flex>
+
+      <BAITable
+        rowKey={'name'}
+        neoStyle
+        resizable
+        size="small"
+        scroll={{ x: 'max-content' }}
+        columns={columns}
+        dataSource={filterNonNullItems(scaling_groups)}
+        loading={isActiveTypePending}
+      />
+    </Flex>
+  );
+};
+
+export default ResourceGroupList;
diff --git a/react/src/components/StorageProxyList.tsx b/react/src/components/StorageProxyList.tsx
index 1866749f92..73aca08892 100644
--- a/react/src/components/StorageProxyList.tsx
+++ b/react/src/components/StorageProxyList.tsx
@@ -122,55 +122,6 @@ const StorageProxyList = () => {
         );
       },
     },
-    {
-      title: t('general.Control'),
-      key: 'control',
-      width: 100,
-      render: (value, record) => {
-        let perfMetricDisabled;
-        try {
-          const performanceMetric = JSON.parse(record.performance_metric);
-          perfMetricDisabled = _.isEmpty(performanceMetric);
-        } catch (e) {
-          perfMetricDisabled = true;
-        }
-        return (
-          <>
-            <Button
-              size="large"
-              disabled={perfMetricDisabled}
-              style={{
-                color: perfMetricDisabled
-                  ? token.colorTextDisabled
-                  : token.colorSuccess,
-              }}
-              icon={<InfoCircleOutlined />}
-              onClick={() => {
-                // This event is being listened to by the plugin
-                const event = new CustomEvent(
-                  'backend-ai-selected-storage-proxy',
-                  {
-                    detail: record.id,
-                  },
-                );
-                document.dispatchEvent(event);
-              }}
-              type="text"
-            />
-            <BAILink to={`/storage-settings/${record.id}`}>
-              <Button
-                size="large"
-                style={{
-                  color: token.colorInfo,
-                }}
-                icon={<SettingOutlined />}
-                type="text"
-              />
-            </BAILink>
-          </>
-        );
-      },
-    },
     {
       title: t('agent.BackendType'),
       key: 'backend',
@@ -252,6 +203,56 @@ const StorageProxyList = () => {
         );
       },
     },
+    {
+      title: t('general.Control'),
+      key: 'control',
+      width: 100,
+      fixed: 'right',
+      render: (value, record) => {
+        let perfMetricDisabled;
+        try {
+          const performanceMetric = JSON.parse(record.performance_metric);
+          perfMetricDisabled = _.isEmpty(performanceMetric);
+        } catch (e) {
+          perfMetricDisabled = true;
+        }
+        return (
+          <>
+            <Button
+              size="large"
+              disabled={perfMetricDisabled}
+              style={{
+                color: perfMetricDisabled
+                  ? token.colorTextDisabled
+                  : token.colorSuccess,
+              }}
+              icon={<InfoCircleOutlined />}
+              onClick={() => {
+                // This event is being listened to by the plugin
+                const event = new CustomEvent(
+                  'backend-ai-selected-storage-proxy',
+                  {
+                    detail: record.id,
+                  },
+                );
+                document.dispatchEvent(event);
+              }}
+              type="text"
+            />
+            <BAILink to={`/storage-settings/${record.id}`}>
+              <Button
+                size="large"
+                style={{
+                  color: token.colorInfo,
+                }}
+                icon={<SettingOutlined />}
+                type="text"
+              />
+            </BAILink>
+          </>
+        );
+      },
+    },
   ];
 
   useEffect(() => {
diff --git a/react/src/pages/ResourcesPage.tsx b/react/src/pages/ResourcesPage.tsx
index 5d8414409b..808a01ac34 100644
--- a/react/src/pages/ResourcesPage.tsx
+++ b/react/src/pages/ResourcesPage.tsx
@@ -1,5 +1,6 @@
 import AgentList from '../components/AgentList';
 import BAICard from '../components/BAICard';
+import ResourceGroupList from '../components/ResourceGroupList';
 import StorageProxyList from '../components/StorageProxyList';
 import { Skeleton, theme } from 'antd';
 import React, { Suspense } from 'react';
@@ -38,35 +39,30 @@ const ResourcesPage: React.FC<ResourcesPageProps> = (props) => {
           tab: t('general.ResourceGroup'),
         },
       ]}
+      styles={{
+        body: {
+          padding: `${token.paddingSM}px ${token.paddingLG}px ${token.paddingLG}px ${token.paddingLG}px`,
+        },
+      }}
     >
       {curTabKey === 'agents' ? (
-        // To remove duplicated border in the bordered table, we need to remove margin of the container.
-        <Suspense
-          fallback={
-            <Skeleton
-              active
-              style={{ padding: token.paddingContentVerticalLG }}
-            />
-          }
-        >
+        <Suspense fallback={<Skeleton active />}>
           <AgentList />
         </Suspense>
       ) : null}
       {curTabKey === 'storages' ? (
-        <Suspense
-          fallback={
-            <Skeleton
-              active
-              style={{ padding: token.paddingContentVerticalLG }}
-            />
-          }
-        >
+        <Suspense fallback={<Skeleton active />}>
           <StorageProxyList />
         </Suspense>
       ) : null}
       {curTabKey === 'resourceGroup' ? (
-        // @ts-ignore
-        <backend-ai-resource-group-list active />
+        <>
+          <Suspense fallback={<Skeleton active />}>
+            <ResourceGroupList />
+          </Suspense>
+          {/* @ts-ignore */}
+          {/* <backend-ai-resource-group-list active /> */}
+        </>
       ) : null}
     </BAICard>
   );


Reviews:
user: login: nowgnuesLee
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T05:32:20Z
user: login: agatha197
body: ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/2HueYSdFvL8pOB5mgrUQ/30aaf010-b63e-4921-b715-e7e8fb8a237d.png)

The agent list table is aligned to the right.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T02:59:37Z
user: login: agatha197
body: Let's fix `Each child in a list should have a unique "key" prop.` warning by adding rowKey to each table.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T03:02:31Z
user: login: agatha197
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T03:05:03Z
user: login: ironAiken2
body: All resolved.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T04:51:44Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the Resource Page UI to use the Neo Style by replacing the standard Ant Design Table with BAITable components and updating the layout and styling across resource list components. Key changes include:  
- Migrating AgentList, StorageProxyList, and a new ResourceGroupList component to BAITable/BAICard components  
- Adjusting pagination display formats, spacing, and control button positioning  
- Improving overall layout consistency and styling in the resources page

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

| File | Description |
| ---- | ----------- |
| react/src/pages/ResourcesPage.tsx | Replaces Card with BAICard and removes extra container style on AgentList |
| react/src/components/StorageProxyList.tsx | Removes BAICard wrapper, adjusts control column position, and updates pagination formatting |
| react/src/components/ResourceGroupList.tsx | Adds a new resource group list component using BAITable with updated column definitions |
| react/src/components/AgentList.tsx | Replaces the standard Table with BAITable and removes the containerStyle prop for layout consistency |


<details>
<summary>Comments suppressed due to low confidence (3)</summary>

**react/src/pages/ResourcesPage.tsx:56**
* The removal of the containerStyle prop on AgentList may impact the intended spacing; confirm that the new layout meets all design requirements.
```
-          <AgentList containerStyle={{ marginLeft: -1, marginRight: -1 }} />
```
**react/src/components/StorageProxyList.tsx:307**
* [nitpick] Consider extracting the pagination showTotal formatting logic into a shared utility or constant to ensure consistency and ease future maintenance across resource lists.
```
showTotal(total, range) { return `${range[0]}-${range[1]} of ${total} items`; },
```
**react/src/components/AgentList.tsx:742**
* [nitpick] Removing the containerStyle prop simplifies the component, but verify that the absence of explicit margin settings does not adversely affect the parent layout.
```
-    <Flex direction="column" align="stretch" style={containerStyle}>
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T04:58:22Z
user: login: agatha197
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T06:12:06Z
user: login: yomybaby
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T02:15:34Z
user: login: yomybaby
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-20T02:22:52Z
user: login: yomybaby
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T06:50:00Z

Review comments:
diff_hunk: @@ -71,10 +70,22 @@ const ResourcesPage: React.FC<ResourcesPageProps> = (props) => {
         </Suspense>
       ) : null}
       {curTabKey === 'resourceGroup' ? (
-        // @ts-ignore
-        <backend-ai-resource-group-list active />
+        <>
+          <Suspense
+            fallback={
+              <Skeleton
+                active
+                style={{ padding: token.paddingContentVerticalLG }}
+              />
+            }
+          >
+            <ResourceGroupList />
+          </Suspense>
+          {/* @ts-ignore */}
+          {/* <backend-ai-resource-group-list active /> */}
+        </>
path: react/src/pages/ResourcesPage.tsx
user: login: agatha197
body: ```suggestion
          <Suspense
            fallback={
              <Skeleton
                active
                style={{ padding: token.paddingContentVerticalLG }}
              />
            }
          >
            <ResourceGroupList />
          </Suspense>
```
created_at: 2025-05-15T03:05:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,10 +70,22 @@ const ResourcesPage: React.FC<ResourcesPageProps> = (props) => {
         </Suspense>
       ) : null}
       {curTabKey === 'resourceGroup' ? (
-        // @ts-ignore
-        <backend-ai-resource-group-list active />
+        <>
+          <Suspense
+            fallback={
+              <Skeleton
+                active
+                style={{ padding: token.paddingContentVerticalLG }}
+              />
+            }
+          >
+            <ResourceGroupList />
+          </Suspense>
+          {/* @ts-ignore */}
+          {/* <backend-ai-resource-group-list active /> */}
+        </>
path: react/src/pages/ResourcesPage.tsx
user: login: ironAiken2
body: It resolved at the top of the PR stack #3636 
created_at: 2025-05-15T04:51:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -39,14 +41,11 @@ const ResourcesPage: React.FC<ResourcesPageProps> = (props) => {
       ]}
       styles={{
         body: {
-          padding: 0,
-          paddingTop: 1,
-          overflow: 'hidden',
+          padding: `${token.paddingSM}px ${token.paddingLG}px ${token.paddingLG}px ${token.paddingLG}px`,
path: react/src/pages/ResourcesPage.tsx
user: login: yomybaby
body: It would be good to create a PR to modify BAICard so that the bodyStyle automatically changes when a tabList is specified.
created_at: 2025-05-20T02:15:33Z
author_association: MEMBER
diff_hunk: @@ -71,10 +70,22 @@ const ResourcesPage: React.FC<ResourcesPageProps> = (props) => {
         </Suspense>
       ) : null}
       {curTabKey === 'resourceGroup' ? (
-        // @ts-ignore
-        <backend-ai-resource-group-list active />
+        <>
+          <Suspense
+            fallback={
+              <Skeleton
+                active
+                style={{ padding: token.paddingContentVerticalLG }}
path: react/src/pages/ResourcesPage.tsx
user: login: yomybaby
body: It seems that excessive padding has been applied due to this style.

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/XqC2uNFuj0wg8I60sMUh/41222d8d-e724-48a4-a89a-70d260d61b78.png)


created_at: 2025-05-20T02:18:33Z
author_association: MEMBER

PR comments:
user: login: ironAiken2
created_at: 2025-05-09T02:28:14Z
body: * **#3636** <a href="https://app.graphite.dev/github/pr/lablup/backend.ai-webui/3636?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#3635** <a href="https://app.graphite.dev/github/pr/lablup/backend.ai-webui/3635?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#3633** <a href="https://app.graphite.dev/github/pr/lablup/backend.ai-webui/3633?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#3626** <a href="https://app.graphite.dev/github/pr/lablup/backend.ai-webui/3626?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/lablup/backend.ai-webui/3626?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`
<hr />

## How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:
- <kbd>flow:merge-queue</kbd> - adds this PR to the back of the merge queue
- <kbd>flow:hotfix</kbd> - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using [this link](https://app.graphite.dev/invite/github/lablup?ref=merge-queue-instructions-in-stack-comment&prId=3626).

<h2></h2>
<sub>

An organization admin has required the [Graphite Merge Queue](https://graphite.dev/docs/graphite-merge-queue) in this repository.

</sub>

<sub>

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

</sub>

<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-09T02:29:38Z
body: <!-- jest coverage report action for options with hash a5e7b399e2c5f8b6aa9d3d8a1831b3db -->

## Coverage report for `./react`


| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category | Percentage | Covered / Total |
| :-: | :- | :- | :-: |
| 🔴 | Statements | <div title="Base coverage is: 3.99%">3.99% (-0.01% 🔻)</div> | 408/10236 |
| 🔴 | Branches | <div title="Base coverage is: 3.35%">3.35% (-0% 🔻)</div> | 240/7161 |
| 🔴 | Functions | <div title="Base coverage is: 2.44%">2.43% (-0.01% 🔻)</div> | 79/3248 |
| 🔴 | Lines | <div title="Base coverage is: 3.93%">3.92% (-0.01% 🔻)</div> | 393/10014 |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File | Statements | Branches | Functions | Lines |
| :-: | :- | :- | :- | :- | :- |
| 🔴 | <div title="components/ResourceGroupList.tsx">`...` / ResourceGroupList.tsx</div> | 0% | 0% | 0% | 0% |

</details>


## Test suite run success
124 tests passing in 14 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from b3d82713efd47c6045b19a11fc528396c870aa38</p>

author_association: CONTRIBUTOR
user: login: graphite-app[bot]
created_at: 2025-05-20T07:08:10Z
body: ### Merge activity

* **May 20, 3:08 AM EDT**: `yomybaby` added this pull request to the [Graphite merge queue](https://app.graphite.dev/merges?org=lablup&repo=backend.ai-webui).
* **May 20, 3:11 AM EDT**: Merged by the [Graphite merge queue](https://app.graphite.dev/merges?org=lablup&repo=backend.ai-webui).

author_association: NONE