refactor: gpu service API alignment

This commit is contained in:
jialin
2026-05-25 22:42:02 +08:00
committed by jialin
parent 81c7e2ee61
commit 88ab927755
90 changed files with 3247 additions and 2023 deletions
+10 -1
View File
@@ -213,10 +213,19 @@ const baseRoutes = [
name: 'storage',
path: '/gpu-service/storage',
key: 'gpuServiceStorage',
icon: 'icon-database-outlined',
selectedIcon: 'icon-database-filled',
defaultIcon: 'icon-database-outlined',
component: './gpu-service/storage'
},
{
name: 'storageTypes',
path: '/gpu-service/storage-types',
key: 'gpuServiceStorageTypes',
icon: 'icon-storage-outlined',
selectedIcon: 'icon-storage-filled',
defaultIcon: 'icon-storage-outlined',
component: './gpu-service/storage'
component: './gpu-service/storage-types'
},
{
name: 'publicKeys',
+1 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.14",
"@gpustack/core-ui": "^1.0.16",
"@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6",
+79 -38
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1
version: 7.1.2
'@gpustack/core-ui':
specifier: ^1.0.14
version: 1.0.14(czdvzceysqw7iv6pct2ucnb23e)
specifier: ^1.0.16
version: 1.0.16(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf':
specifier: ^0.1.7
version: 0.1.18
@@ -49,7 +49,7 @@ importers:
version: 4.17.24
'@umijs/max':
specifier: ^4.6.15
version: 4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lightningcss@1.22.1)(prettier@3.8.3)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
version: 4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lightningcss@1.22.1)(prettier@3.8.3)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@xterm/addon-fit':
specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -205,7 +205,7 @@ importers:
version: 6.4.1(css-to-react-native@3.2.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
umi-presets-pro:
specifier: ^2.0.3
version: 2.0.3(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(chokidar@3.6.0)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(encoding@0.1.13)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)))
version: 2.0.3(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(chokidar@3.6.0)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(encoding@0.1.13)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)))
wavesurfer.js:
specifier: ^7.8.8
version: 7.12.6
@@ -1484,8 +1484,8 @@ packages:
resolution: {integrity: sha512-KWk80UPIzPmUg+P0rKh6TqspRw0G6eux1PuJr+zz47ftMaZ9QDwbGzHZbtzWkl5hgayM/qrKRutllRC7D/vVXQ==, tarball: https://registry.npmjs.org/@formatjs/intl-utils/-/intl-utils-2.3.0.tgz}
deprecated: the package is rather renamed to @formatjs/ecma-abstract with some changes in functionality (primarily selectUnit is removed and we don't plan to make any further changes to this package
'@gpustack/core-ui@1.0.14':
resolution: {integrity: sha512-nmkwYt4YA815uzaXc/9KZ4dGe0bW8FkD9yFXL8xoFXPH5gjGg8DGGXDvWJ4UqG3IIPjY6whwYm61sMxgfK192Q==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.14.tgz}
'@gpustack/core-ui@1.0.16':
resolution: {integrity: sha512-wFKDv7X0FXRmAmZPt4WKYV0+aGHcx82/3EZSJkrut/49XQd5XdrqKaimGtlFWFCNEWqsouwzs4uqaC7JFqfOkQ==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.16.tgz}
peerDependencies:
'@ant-design/icons': '>=6.0.0'
'@ant-design/pro-components': 3.1.0-0
@@ -2463,6 +2463,9 @@ packages:
'@types/node@25.6.2':
resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==, tarball: https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz}
'@types/node@25.9.1':
resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==, tarball: https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==, tarball: https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz}
@@ -2492,6 +2495,9 @@ packages:
'@types/react@18.3.28':
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==, tarball: https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz}
'@types/react@18.3.29':
resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==, tarball: https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz}
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, tarball: https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz}
@@ -4402,6 +4408,10 @@ packages:
resolution: {integrity: sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz}
engines: {node: '>=10.13.0'}
enhanced-resolve@5.22.0:
resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz}
engines: {node: '>=10.13.0'}
enhanced-resolve@5.9.3:
resolution: {integrity: sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz}
engines: {node: '>=10.13.0'}
@@ -8605,6 +8615,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
terser@5.48.0:
resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==, tarball: https://registry.npmjs.org/terser/-/terser-5.48.0.tgz}
engines: {node: '>=10'}
hasBin: true
test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==, tarball: https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz}
engines: {node: '>=8'}
@@ -8818,6 +8833,9 @@ packages:
undici-types@7.19.2:
resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz}
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz}
unfetch@5.0.0:
resolution: {integrity: sha512-3xM2c89siXg0nHvlmYsQ2zkLASvVMBisZm5lF3gFDqfF2xonNStDJyMpvaOBe0a1Edxmqrf2E0HBdmy9QyZaeg==, tarball: https://registry.npmjs.org/unfetch/-/unfetch-5.0.0.tgz}
@@ -9050,8 +9068,8 @@ packages:
engines: {node: '>= 10.13.0'}
hasBin: true
webpack-sources@3.4.1:
resolution: {integrity: sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==, tarball: https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz}
webpack-sources@3.5.0:
resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==, tarball: https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz}
engines: {node: '>=10.13.0'}
webpack@5.106.2:
@@ -10790,7 +10808,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.14(czdvzceysqw7iv6pct2ucnb23e)':
'@gpustack/core-ui@1.0.16(czdvzceysqw7iv6pct2ucnb23e)':
dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@ant-design/pro-components': 3.1.0-0(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -11811,7 +11829,7 @@ snapshots:
'@types/history@5.0.0':
dependencies:
history: 5.3.0
history: 4.10.1
'@types/hoist-non-react-statics@3.3.7(@types/react@18.3.28)':
dependencies:
@@ -11865,6 +11883,10 @@ snapshots:
dependencies:
undici-types: 7.19.2
'@types/node@25.9.1':
dependencies:
undici-types: 7.24.6
'@types/normalize-package-data@2.4.4': {}
'@types/parse-json@4.0.2': {}
@@ -11885,26 +11907,31 @@ snapshots:
'@types/react-router-dom@4.3.5':
dependencies:
'@types/history': 5.0.0
'@types/react': 18.3.28
'@types/react': 18.3.29
'@types/react-router': 5.1.20
'@types/react-router-redux@5.0.27':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.3.28
'@types/react': 18.3.29
'@types/react-router': 5.1.20
redux: 4.2.1
redux: 3.7.2
'@types/react-router@5.1.20':
dependencies:
'@types/history': 4.7.11
'@types/react': 18.3.28
'@types/react': 18.3.29
'@types/react@18.3.28':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/react@18.3.29':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/resolve@1.20.6': {}
'@types/semver@7.7.1': {}
@@ -12287,18 +12314,18 @@ snapshots:
- webpack-hot-middleware
- webpack-plugin-serve
'@umijs/bundler-vite@4.6.51(@types/node@25.6.2)(lightningcss@1.22.1)(postcss@8.5.14)(rollup@3.30.0)(sass@1.54.0)(terser@5.47.1)':
'@umijs/bundler-vite@4.6.51(@types/node@25.6.2)(lightningcss@1.22.1)(postcss@8.5.14)(rollup@3.30.0)(sass@1.54.0)(terser@5.48.0)':
dependencies:
'@svgr/core': 6.5.1
'@umijs/bundler-utils': 4.6.51
'@umijs/utils': 4.6.51
'@vitejs/plugin-react': 4.0.0(vite@4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.47.1))
'@vitejs/plugin-react': 4.0.0(vite@4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.48.0))
core-js: 3.34.0
less: 4.1.3
postcss-preset-env: 7.5.0(postcss@8.5.14)
rollup-plugin-visualizer: 5.9.0(rollup@3.30.0)
systemjs: 6.15.1
vite: 4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.47.1)
vite: 4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.48.0)
transitivePeerDependencies:
- '@types/node'
- lightningcss
@@ -12526,14 +12553,14 @@ snapshots:
- supports-color
- typescript
'@umijs/max@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lightningcss@1.22.1)(prettier@3.8.3)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))':
'@umijs/max@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(lightningcss@1.22.1)(prettier@3.8.3)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))':
dependencies:
'@umijs/lint': 4.6.51(eslint@8.35.0)(stylelint@14.8.2)(typescript@5.9.3)
'@umijs/plugins': 4.6.51(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@4.24.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
antd: 4.24.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
eslint: 8.35.0
stylelint: 14.8.2
umi: 4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@8.35.0)(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
umi: 4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@8.35.0)(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
transitivePeerDependencies:
- '@babel/core'
- '@rspack/core'
@@ -12750,7 +12777,7 @@ snapshots:
- react-native
- supports-color
'@umijs/preset-umi@4.6.51(@types/node@25.6.2)(@types/react@18.3.28)(lightningcss@1.22.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))':
'@umijs/preset-umi@4.6.51(@types/node@25.6.2)(@types/react@18.3.28)(lightningcss@1.22.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))':
dependencies:
'@iconify/utils': 2.1.1
'@stagewise/toolbar': 0.6.2
@@ -12761,7 +12788,7 @@ snapshots:
'@umijs/bundler-mako': 0.11.10(postcss@8.5.14)(sass@1.54.0)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/bundler-utils': 4.6.51
'@umijs/bundler-utoopack': 4.6.51(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/bundler-vite': 4.6.51(@types/node@25.6.2)(lightningcss@1.22.1)(postcss@8.5.14)(rollup@3.30.0)(sass@1.54.0)(terser@5.47.1)
'@umijs/bundler-vite': 4.6.51(@types/node@25.6.2)(lightningcss@1.22.1)(postcss@8.5.14)(rollup@3.30.0)(sass@1.54.0)(terser@5.48.0)
'@umijs/bundler-webpack': 4.6.51(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/core': 4.6.51
'@umijs/did-you-know': 1.0.4
@@ -12843,13 +12870,13 @@ snapshots:
react-helmet-async: 1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router-dom: 6.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@umijs/request-record@1.1.4(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)))':
'@umijs/request-record@1.1.4(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)))':
dependencies:
chokidar: 3.6.0
express: 4.22.1
lodash: 4.18.1
prettier: 2.8.8
umi: 4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
umi: 4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
transitivePeerDependencies:
- supports-color
@@ -13027,13 +13054,13 @@ snapshots:
- supports-color
- utf-8-validate
'@vitejs/plugin-react@4.0.0(vite@4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.47.1))':
'@vitejs/plugin-react@4.0.0(vite@4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.48.0))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
'@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
react-refresh: 0.14.2
vite: 4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.47.1)
vite: 4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.48.0)
transitivePeerDependencies:
- supports-color
@@ -14645,6 +14672,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
enhanced-resolve@5.22.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
enhanced-resolve@5.9.3:
dependencies:
graceful-fs: 4.2.11
@@ -16358,7 +16390,7 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 25.6.2
'@types/node': 25.9.1
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -19928,7 +19960,7 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
terser: 5.47.1
terser: 5.48.0
webpack: 5.106.2(lightningcss@1.22.1)(postcss@8.5.14)
optionalDependencies:
lightningcss: 1.22.1
@@ -19941,6 +19973,13 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
terser@5.48.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.6
@@ -20125,12 +20164,12 @@ snapshots:
ua-parser-js@0.7.41: {}
umi-presets-pro@2.0.3(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(chokidar@3.6.0)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(encoding@0.1.13)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))):
umi-presets-pro@2.0.3(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(chokidar@3.6.0)(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(encoding@0.1.13)(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))):
dependencies:
'@alita/plugins': 3.5.5(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@umijs/max-plugin-openapi': 2.0.3(chokidar@3.6.0)(encoding@0.1.13)(typescript@5.9.3)
'@umijs/plugins': 4.6.51(@babel/core@7.29.0)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(antd@6.3.7(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(dva@2.5.0-beta.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(rc-field-form@2.7.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@umijs/request-record': 1.1.4(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)))
'@umijs/request-record': 1.1.4(umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)))
swagger-ui-dist: 4.19.1
transitivePeerDependencies:
- '@babel/core'
@@ -20154,14 +20193,14 @@ snapshots:
isomorphic-fetch: 2.2.1
qs: 6.15.1
umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@8.35.0)(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)):
umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@8.35.0)(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)):
dependencies:
'@babel/runtime': 7.23.6
'@umijs/bundler-utils': 4.6.51
'@umijs/bundler-webpack': 4.6.51(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/core': 4.6.51
'@umijs/lint': 4.6.51(eslint@8.35.0)(stylelint@14.8.2)(typescript@5.9.3)
'@umijs/preset-umi': 4.6.51(@types/node@25.6.2)(@types/react@18.3.28)(lightningcss@1.22.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/preset-umi': 4.6.51(@types/node@25.6.2)(@types/react@18.3.28)(lightningcss@1.22.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/renderer-react': 4.6.51(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@umijs/server': 4.6.51
'@umijs/test': 4.6.51(@babel/core@7.29.0)
@@ -20208,14 +20247,14 @@ snapshots:
- webpack-hot-middleware
- webpack-plugin-serve
umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)):
umi@4.6.51(@babel/core@7.29.0)(@types/node@25.6.2)(@types/react@18.3.28)(eslint@9.39.4(jiti@2.7.0))(lightningcss@1.22.1)(prettier@3.8.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(stylelint@14.8.2)(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14)):
dependencies:
'@babel/runtime': 7.23.6
'@umijs/bundler-utils': 4.6.51
'@umijs/bundler-webpack': 4.6.51(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/core': 4.6.51
'@umijs/lint': 4.6.51(eslint@9.39.4(jiti@2.7.0))(stylelint@14.8.2)(typescript@5.9.3)
'@umijs/preset-umi': 4.6.51(@types/node@25.6.2)(@types/react@18.3.28)(lightningcss@1.22.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.47.1)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/preset-umi': 4.6.51(@types/node@25.6.2)(@types/react@18.3.28)(lightningcss@1.22.1)(rollup@3.30.0)(sass@1.54.0)(styled-jsx@5.1.7(@babel/core@7.29.0)(react@18.3.1))(terser@5.48.0)(type-fest@0.20.2)(typescript@5.9.3)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
'@umijs/renderer-react': 4.6.51(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@umijs/server': 4.6.51
'@umijs/test': 4.6.51(@babel/core@7.29.0)
@@ -20273,6 +20312,8 @@ snapshots:
undici-types@7.19.2: {}
undici-types@7.24.6: {}
unfetch@5.0.0: {}
unified@11.0.5:
@@ -20457,7 +20498,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.47.1):
vite@4.5.2(@types/node@25.6.2)(less@4.1.3)(lightningcss@1.22.1)(sass@1.54.0)(terser@5.48.0):
dependencies:
esbuild: 0.18.20
postcss: 8.5.14
@@ -20468,7 +20509,7 @@ snapshots:
less: 4.1.3
lightningcss: 1.22.1
sass: 1.54.0
terser: 5.47.1
terser: 5.48.0
vm-browserify@1.1.2: {}
@@ -20530,7 +20571,7 @@ snapshots:
- bufferutil
- utf-8-validate
webpack-sources@3.4.1: {}
webpack-sources@3.5.0: {}
webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14):
dependencies:
@@ -20544,7 +20585,7 @@ snapshots:
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.2
chrome-trace-event: 1.0.4
enhanced-resolve: 5.21.2
enhanced-resolve: 5.22.0
es-module-lexer: 2.1.0
eslint-scope: 5.1.1
events: 3.3.0
@@ -20557,7 +20598,7 @@ snapshots:
tapable: 2.3.3
terser-webpack-plugin: 5.6.0(lightningcss@1.22.1)(postcss@8.5.14)(webpack@5.106.2(lightningcss@1.22.1)(postcss@8.5.14))
watchpack: 2.5.1
webpack-sources: 3.4.1
webpack-sources: 3.5.0
transitivePeerDependencies:
- '@minify-html/node'
- '@swc/core'
+2 -4
View File
@@ -66,9 +66,7 @@ const NO_CONTAINER_PAGES = [
'clusterCreate',
'benchmarkDetail',
'deployment',
'video',
'instances',
'storage'
'video'
];
const CHECK_RESOURCE_PATH = [
@@ -358,7 +356,7 @@ export default (props: any) => {
config={{
apiBaseUrl: GPUSTACK_API_BASE_URL,
theme: userSettings.theme,
iconUrl: '//at.alicdn.com/t/c/font_4613488_13sztx8tdru.js',
iconUrl: '//at.alicdn.com/t/c/font_4613488_jmdepcs90im.js',
isDarkTheme: userSettings.isDarkTheme,
defaultColorPrimary: COLOR_PRIMARY
}}
+2
View File
@@ -58,6 +58,7 @@ export default {
'common.table.createTime': 'Created',
'common.table.updateTime': 'Updated',
'common.table.description': 'Description',
'common.table.displayName': 'Display Name',
'common.table.name': 'Name',
'common.table.status': 'Status',
'common.table.name.list': '{type} Name',
@@ -283,5 +284,6 @@ export default {
'common.image.limit.width': 'Image width must be {width}.',
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': 'Remaining {count}',
'common.max': 'Maximum {count}',
'common.validate.group': 'Please complete the {group} configuration'
};
+34
View File
@@ -39,6 +39,35 @@ export default {
'gpuservice.template.card.mount': 'Mount',
'gpuservice.template.card.resources': 'Resources',
'gpuservice.template.card.ports': 'Ports',
'gpuservice.storageType': 'Storage Type',
'gpuservice.storageType.add': 'Add Storage Type',
'gpuservice.storageType.edit': 'Edit Storage Type',
'gpuservice.storageType.filter.name': 'Search by name',
'gpuservice.storageType.kind': 'Type',
'gpuservice.storageType.mountOptions': 'Mount Options',
'gpuservice.storageType.nfs.server': 'NFS Server',
'gpuservice.storageType.nfs.share': 'Share Path',
'gpuservice.storageType.nfs.subDirectory': 'Sub Directory',
'gpuservice.storageType.nfs.subDirectory.tips':
'If empty, a subdirectory named after the persistent volume will be created. If set, a directory with the persistent volume name will be created beneath this subdirectory.',
'gpuservice.storageType.nfs.mountPermissions': 'Mount Permissions',
'gpuservice.storageType.nfs.mountPermissions.tips':
'Inherit the file permissions from the NFS server.',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.rule': 'Must start with http or https',
'gpuservice.storageType.s3.region': 'Region',
'gpuservice.storageType.s3.bucket': 'Bucket',
'gpuservice.storageType.s3.bucket.tips':
'If empty, a new bucket named after the persistent volume will be created. If set, a subdirectory with the persistent volume name will be created inside this bucket.',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'Skip TLS/SSL certificate verification',
'gpuservice.storageType.s3.insecure.tips':
'When enabled, the S3 server certificate is not validated. Use this for internal testing or self-signed certificates; enable with caution in production.',
'gpuservice.publicKey': 'SSH Public Key',
'gpuservice.publicKey.add': 'Add SSH Public Key',
'gpuservice.publicKey.edit': 'Edit SSH Public Key',
'gpuservice.publicKey.filter.name': 'Search by name',
'gpuservice.publicKey.label': 'SSH Public Key',
'gpuservice.instance.ssh.enable': 'Enable SSH Access',
'gpuservice.instance.ssh.assignKey': 'Assign SSH Public Key',
@@ -108,6 +137,11 @@ export default {
'Data persists across restarts and is deleted only when the instance is terminated. Cannot be shared with other instances.',
'gpuservice.storage.persistentVolume.required':
'Please select a persistent volume',
'gpuservice.storage.persistentVolume.capacity': 'Capacity (GB)',
'gpuservice.storage.persistentVolume.capacity.required':
'Please enter capacity',
'gpuservice.storage.persistentVolume.releaseWithInstance':
'Release with instance',
'gpuservice.storage.tempCapacity': 'Capacity (GB)',
'gpuservice.storage.tempCapacity.required':
'Please enter the temporary storage capacity',
+1
View File
@@ -45,5 +45,6 @@ export default {
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'Storage Types',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
+4
View File
@@ -65,6 +65,10 @@ export default {
'noresult.gpuservice.storage.title': 'No Storage',
'noresult.gpuservice.storage.subTitle': 'No storage has been added yet.',
'noresult.gpuservice.storage.nofound': 'No matching storage found.',
'noresult.gpuservice.storageType.title': 'No Storage Types',
'noresult.gpuservice.storageType.subTitle':
'No storage types have been added yet.',
'noresult.gpuservice.storageType.nofound': 'No matching storage types found.',
'noresult.gpuservice.sshkey.title': 'No SSH Public Keys',
'noresult.gpuservice.sshkey.subTitle':
'No SSH public keys have been added yet.',
+2
View File
@@ -58,6 +58,7 @@ export default {
'common.table.createTime': '作成日時',
'common.table.updateTime': '更新日時',
'common.table.description': '説明',
'common.table.displayName': '表示名',
'common.table.name': '名前',
'common.table.status': 'ステータス',
'common.table.name.list': '{type} 名称',
@@ -283,6 +284,7 @@ export default {
'common.image.limit.width': 'Image width must be {width}.',
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': '残り {count}',
'common.max': 'Maximum {count}',
'common.validate.group': 'Please complete the {group} configuration'
};
+35
View File
@@ -37,6 +37,36 @@ export default {
'gpuservice.template.card.mount': 'マウント',
'gpuservice.template.card.resources': 'リソース',
'gpuservice.template.card.ports': 'ポート',
'gpuservice.storageType': 'ストレージタイプ',
'gpuservice.storageType.add': 'ストレージタイプを追加',
'gpuservice.storageType.edit': 'ストレージタイプを編集',
'gpuservice.storageType.filter.name': '名前で検索',
'gpuservice.storageType.kind': '種別',
'gpuservice.storageType.mountOptions': 'マウントオプション',
'gpuservice.storageType.nfs.server': 'NFS サーバー',
'gpuservice.storageType.nfs.share': '共有パス',
'gpuservice.storageType.nfs.subDirectory': 'サブディレクトリ',
'gpuservice.storageType.nfs.subDirectory.tips':
'空の場合、永続ボリューム名のサブディレクトリが作成されます。設定されている場合、このサブディレクトリ配下に永続ボリューム名のディレクトリが作成されます。',
'gpuservice.storageType.nfs.mountPermissions': 'マウント権限',
'gpuservice.storageType.nfs.mountPermissions.tips':
'NFS サーバー上のファイル権限を継承します。',
'gpuservice.storageType.s3.endpoint': 'エンドポイント',
'gpuservice.storageType.s3.endpoint.rule':
'http または https で始まる必要があります',
'gpuservice.storageType.s3.region': 'リージョン',
'gpuservice.storageType.s3.bucket': 'バケット',
'gpuservice.storageType.s3.bucket.tips':
'空の場合、永続ボリューム名で新しいバケットが作成されます。設定されている場合、このバケット配下に永続ボリューム名のサブディレクトリが作成されます。',
'gpuservice.storageType.s3.accessKey': 'アクセスキー',
'gpuservice.storageType.s3.secretKey': 'シークレットキー',
'gpuservice.storageType.s3.insecure': 'TLS/SSL 証明書の検証をスキップ',
'gpuservice.storageType.s3.insecure.tips':
'有効にすると S3 サーバーの証明書検証を無視します。社内テストや自己署名証明書の利用時に適しており、本番環境では慎重に有効化してください。',
'gpuservice.publicKey': 'SSH 公開鍵',
'gpuservice.publicKey.add': 'SSH 公開鍵を追加',
'gpuservice.publicKey.edit': 'SSH 公開鍵を編集',
'gpuservice.publicKey.filter.name': '名前で検索',
'gpuservice.publicKey.label': 'SSH 公開鍵',
'gpuservice.instance.ssh.enable': 'SSH アクセスを有効化',
'gpuservice.instance.ssh.assignKey': 'SSH 公開鍵を割り当て',
@@ -102,6 +132,11 @@ export default {
'gpuservice.storage.persistentVolume': '永続ボリューム',
'gpuservice.storage.persistentVolume.required':
'永続ボリュームを選択してください',
'gpuservice.storage.persistentVolume.capacity': '容量 (GB)',
'gpuservice.storage.persistentVolume.capacity.required':
'容量を入力してください',
'gpuservice.storage.persistentVolume.releaseWithInstance':
'インスタンスと共に解放',
'gpuservice.storage.tempCapacity': '容量 (GB)',
'gpuservice.storage.tempCapacity.required':
'一時ストレージ容量を入力してください',
+1
View File
@@ -45,6 +45,7 @@ export default {
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'ストレージタイプ',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
+5
View File
@@ -67,6 +67,11 @@ export default {
'noresult.gpuservice.storage.subTitle':
'ストレージはまだ追加されていません。',
'noresult.gpuservice.storage.nofound': '一致するストレージが見つかりません。',
'noresult.gpuservice.storageType.title': 'ストレージタイプなし',
'noresult.gpuservice.storageType.subTitle':
'ストレージタイプはまだ追加されていません。',
'noresult.gpuservice.storageType.nofound':
'一致するストレージタイプが見つかりません。',
'noresult.gpuservice.sshkey.title': 'SSH 公開鍵なし',
'noresult.gpuservice.sshkey.subTitle': 'SSH 公開鍵はまだ追加されていません。',
'noresult.gpuservice.sshkey.nofound': '一致する SSH 公開鍵が見つかりません。'
+2
View File
@@ -58,6 +58,7 @@ export default {
'common.table.createTime': 'Создано',
'common.table.updateTime': 'Обновлено',
'common.table.description': 'Описание',
'common.table.displayName': 'Отображаемое имя',
'common.table.name': 'Название',
'common.table.status': 'Статус',
'common.table.name.list': 'Название {type}',
@@ -282,6 +283,7 @@ export default {
'common.image.limit.width': 'Image width must be {width}.',
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': 'Остаток {count}',
'common.max': 'Maximum {count}',
'common.validate.group': 'Please complete the {group} configuration'
};
+34
View File
@@ -40,6 +40,36 @@ export default {
'gpuservice.template.card.mount': 'Монтирование',
'gpuservice.template.card.resources': 'Ресурсы',
'gpuservice.template.card.ports': 'Порты',
'gpuservice.storageType': 'Тип хранилища',
'gpuservice.storageType.add': 'Добавить тип хранилища',
'gpuservice.storageType.edit': 'Изменить тип хранилища',
'gpuservice.storageType.filter.name': 'Поиск по имени',
'gpuservice.storageType.kind': 'Тип',
'gpuservice.storageType.mountOptions': 'Параметры монтирования',
'gpuservice.storageType.nfs.server': 'Сервер NFS',
'gpuservice.storageType.nfs.share': 'Путь общего ресурса',
'gpuservice.storageType.nfs.subDirectory': 'Подкаталог',
'gpuservice.storageType.nfs.subDirectory.tips':
'Если поле пустое, будет создан подкаталог с именем постоянного тома. Если задано, под этим подкаталогом будет создан каталог с именем постоянного тома.',
'gpuservice.storageType.nfs.mountPermissions': 'Права монтирования',
'gpuservice.storageType.nfs.mountPermissions.tips':
'Наследует права файлов с NFS-сервера.',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.rule':
'Должен начинаться с http или https',
'gpuservice.storageType.s3.region': 'Регион',
'gpuservice.storageType.s3.bucket': 'Бакет',
'gpuservice.storageType.s3.bucket.tips':
'Если поле пустое, будет создан новый бакет с именем постоянного тома. Если задано, в этом бакете будет создан подкаталог с именем постоянного тома.',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'Пропустить проверку сертификата TLS/SSL',
'gpuservice.storageType.s3.insecure.tips':
'Если включено, сертификат сервера S3 не проверяется. Подходит для внутреннего тестирования или самоподписанных сертификатов; в производственной среде включайте с осторожностью.',
'gpuservice.publicKey': 'Открытый ключ SSH',
'gpuservice.publicKey.add': 'Добавить открытый ключ SSH',
'gpuservice.publicKey.edit': 'Изменить открытый ключ SSH',
'gpuservice.publicKey.filter.name': 'Поиск по имени',
'gpuservice.publicKey.label': 'Открытый ключ SSH',
'gpuservice.instance.ssh.enable': 'Включить SSH-доступ',
'gpuservice.instance.ssh.assignKey': 'Назначить открытый ключ SSH',
@@ -102,6 +132,10 @@ export default {
'gpuservice.storage.temporary': 'Временное',
'gpuservice.storage.persistentVolume': 'Постоянный том',
'gpuservice.storage.persistentVolume.required': 'Выберите постоянный том',
'gpuservice.storage.persistentVolume.capacity': 'Ёмкость (ГБ)',
'gpuservice.storage.persistentVolume.capacity.required': 'Введите ёмкость',
'gpuservice.storage.persistentVolume.releaseWithInstance':
'Освобождать вместе с экземпляром',
'gpuservice.storage.tempCapacity': 'Объём (ГБ)',
'gpuservice.storage.tempCapacity.required':
'Введите объём временного хранилища',
+1
View File
@@ -45,6 +45,7 @@ export default {
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'Типы хранилищ',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
+5
View File
@@ -66,6 +66,11 @@ export default {
'noresult.gpuservice.storage.title': 'Нет хранилищ',
'noresult.gpuservice.storage.subTitle': 'Хранилища ещё не добавлены.',
'noresult.gpuservice.storage.nofound': 'Подходящие хранилища не найдены.',
'noresult.gpuservice.storageType.title': 'Нет типов хранилищ',
'noresult.gpuservice.storageType.subTitle':
'Типы хранилищ ещё не добавлены.',
'noresult.gpuservice.storageType.nofound':
'Подходящие типы хранилищ не найдены.',
'noresult.gpuservice.sshkey.title': 'Нет открытых ключей SSH',
'noresult.gpuservice.sshkey.subTitle': 'Открытые ключи SSH ещё не добавлены.',
'noresult.gpuservice.sshkey.nofound':
+2
View File
@@ -58,6 +58,7 @@ export default {
'common.table.createTime': 'Oluşturulma',
'common.table.updateTime': 'Güncellenme',
'common.table.description': 'Açıklama',
'common.table.displayName': 'Görünen Ad',
'common.table.name': 'Ad',
'common.table.status': 'Durum',
'common.table.name.list': '{type} Adı',
@@ -286,5 +287,6 @@ export default {
'common.image.limit.width': 'Image width must be {width}.',
'common.image.limit.height': 'Image height must be {height}.',
'common.remaining': 'Kalan {count}',
'common.max': 'Maksimum {count}',
'common.validate.group': 'Please complete the {group} configuration'
};
+35
View File
@@ -39,6 +39,36 @@ export default {
'gpuservice.template.card.mount': 'Bağlama',
'gpuservice.template.card.resources': 'Kaynaklar',
'gpuservice.template.card.ports': 'Bağlantı Noktaları',
'gpuservice.storageType': 'Depolama Türü',
'gpuservice.storageType.add': 'Depolama Türü Ekle',
'gpuservice.storageType.edit': 'Depolama Türünü Düzenle',
'gpuservice.storageType.filter.name': 'Ada göre ara',
'gpuservice.storageType.kind': 'Tür',
'gpuservice.storageType.mountOptions': 'Bağlama Seçenekleri',
'gpuservice.storageType.nfs.server': 'NFS Sunucusu',
'gpuservice.storageType.nfs.share': 'Paylaşım Yolu',
'gpuservice.storageType.nfs.subDirectory': 'Alt Dizin',
'gpuservice.storageType.nfs.subDirectory.tips':
'Boş bırakılırsa kalıcı hacim adıyla bir alt dizin oluşturulur. Doldurulursa bu alt dizinin altında kalıcı hacim adıyla bir dizin oluşturulur.',
'gpuservice.storageType.nfs.mountPermissions': 'Bağlama İzinleri',
'gpuservice.storageType.nfs.mountPermissions.tips':
'NFS sunucusundaki dosya izinleri devralınır.',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.rule':
'http veya https ile başlamalıdır',
'gpuservice.storageType.s3.region': 'Bölge',
'gpuservice.storageType.s3.bucket': 'Kova',
'gpuservice.storageType.s3.bucket.tips':
'Boş bırakılırsa kalıcı hacim adıyla yeni bir kova oluşturulur. Doldurulursa bu kova içinde kalıcı hacim adıyla bir alt dizin oluşturulur.',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': 'TLS/SSL sertifika doğrulamasını atla',
'gpuservice.storageType.s3.insecure.tips':
'Etkinleştirildiğinde S3 sunucu sertifikası doğrulanmaz. İç ağ testleri veya kendinden imzalı sertifikalar için uygundur; üretim ortamında dikkatli kullanın.',
'gpuservice.publicKey': 'SSH Açık Anahtarı',
'gpuservice.publicKey.add': 'SSH Açık Anahtarı Ekle',
'gpuservice.publicKey.edit': 'SSH Açık Anahtarını Düzenle',
'gpuservice.publicKey.filter.name': 'Ada göre ara',
'gpuservice.publicKey.label': 'SSH Açık Anahtarı',
'gpuservice.instance.ssh.enable': 'SSH Erişimini Etkinleştir',
'gpuservice.instance.ssh.assignKey': 'SSH Açık Anahtarı Ata',
@@ -103,6 +133,11 @@ export default {
'gpuservice.storage.persistentVolume': 'Kalıcı Hacim',
'gpuservice.storage.persistentVolume.required':
'Lütfen bir kalıcı hacim seçin',
'gpuservice.storage.persistentVolume.capacity': 'Kapasite (GB)',
'gpuservice.storage.persistentVolume.capacity.required':
'Lütfen kapasiteyi girin',
'gpuservice.storage.persistentVolume.releaseWithInstance':
'Örnekle birlikte serbest bırak',
'gpuservice.storage.tempCapacity': 'Kapasite (GB)',
'gpuservice.storage.tempCapacity.required':
'Lütfen geçici depolama kapasitesini girin',
+1
View File
@@ -45,6 +45,7 @@ export default {
'menu.gpuService.instances': 'GPU Instances',
'menu.gpuService.templates': 'Instance Templates',
'menu.gpuService.storage': 'Storage',
'menu.gpuService.storageTypes': 'Depolama Türleri',
'menu.gpuService.publicKeys': 'SSH Public Keys'
};
+4
View File
@@ -64,6 +64,10 @@ export default {
'noresult.gpuservice.storage.title': 'Depolama Yok',
'noresult.gpuservice.storage.subTitle': 'Henüz depolama eklenmedi.',
'noresult.gpuservice.storage.nofound': 'Eşleşen depolama bulunamadı.',
'noresult.gpuservice.storageType.title': 'Depolama Türü Yok',
'noresult.gpuservice.storageType.subTitle': 'Henüz depolama türü eklenmedi.',
'noresult.gpuservice.storageType.nofound':
'Eşleşen depolama türü bulunamadı.',
'noresult.gpuservice.sshkey.title': 'SSH Açık Anahtarı Yok',
'noresult.gpuservice.sshkey.subTitle': 'Henüz SSH açık anahtarı eklenmedi.',
'noresult.gpuservice.sshkey.nofound': 'Eşleşen SSH açık anahtarı bulunamadı.'
+2
View File
@@ -56,6 +56,7 @@ export default {
'common.table.createTime': '创建时间',
'common.table.updateTime': '更新时间',
'common.table.description': '描述',
'common.table.displayName': '显示名称',
'common.table.name': '名称',
'common.table.status': '状态',
'common.table.name.list': '{type}名称',
@@ -276,5 +277,6 @@ export default {
'common.image.limit.width': '图片宽度须为{width}。',
'common.image.limit.height': '图片高度须为{height}。',
'common.remaining': '剩余 {count}',
'common.max': '最大 {count}',
'common.validate.group': '请填写完整的{group}配置'
};
+34 -2
View File
@@ -15,8 +15,8 @@ export default {
'gpuservice.template.mountPath': '挂载路径',
'gpuservice.template.containerDisk': '容器磁盘(GB)',
'gpuservice.template.memory': '内存(GB)',
'gpuservice.instance.containerDisk.remaining': '容器磁盘(剩余{count}GB)',
'gpuservice.instance.memory.remaining': '内存(剩余{count}GB)',
'gpuservice.instance.containerDisk.remaining': '容器磁盘(最大{count}GB)',
'gpuservice.instance.memory.remaining': '内存(最大{count}GB)',
'gpuservice.template.displayName': '显示名称',
'gpuservice.template.displayName.max': '显示名称不能超过 63 个字符',
'gpuservice.template.ports': '端口',
@@ -34,6 +34,35 @@ export default {
'gpuservice.template.card.mount': '挂载',
'gpuservice.template.card.resources': '资源',
'gpuservice.template.card.ports': '端口',
'gpuservice.storageType': '存储类型',
'gpuservice.storageType.add': '添加存储类型',
'gpuservice.storageType.edit': '编辑存储类型',
'gpuservice.storageType.filter.name': '按名称搜索',
'gpuservice.storageType.kind': '类型',
'gpuservice.storageType.mountOptions': '挂载参数',
'gpuservice.storageType.nfs.server': 'NFS 服务器',
'gpuservice.storageType.nfs.share': '共享路径',
'gpuservice.storageType.nfs.subDirectory': '子目录',
'gpuservice.storageType.nfs.subDirectory.tips':
'如果为空,会以持久卷的卷名创建一个子目录;如果不为空,会在同名子目录下,以持久卷的卷名创建一个孙目录。',
'gpuservice.storageType.nfs.mountPermissions': '挂载权限',
'gpuservice.storageType.nfs.mountPermissions.tips':
'沿用 NFS 服务器文件的权限。',
'gpuservice.storageType.s3.endpoint': 'Endpoint',
'gpuservice.storageType.s3.endpoint.rule': '必须以 http 或 https 开头',
'gpuservice.storageType.s3.region': '区域',
'gpuservice.storageType.s3.bucket': '存储桶',
'gpuservice.storageType.s3.bucket.tips':
'如果为空,会以持久卷的卷名创建一个新桶;如果不为空,会在同名桶下,以持久卷的卷名创建一个子目录。',
'gpuservice.storageType.s3.accessKey': 'Access Key',
'gpuservice.storageType.s3.secretKey': 'Secret Key',
'gpuservice.storageType.s3.insecure': '跳过 TLS/SSL 证书验证',
'gpuservice.storageType.s3.insecure.tips':
'开启后将忽略 S3 服务端的证书安全检查。适用于内网测试或使用自签名证书的场景,生产环境请谨慎勾选。',
'gpuservice.publicKey': 'SSH 公钥',
'gpuservice.publicKey.add': '添加 SSH 公钥',
'gpuservice.publicKey.edit': '编辑 SSH 公钥',
'gpuservice.publicKey.filter.name': '按名称搜索',
'gpuservice.publicKey.label': 'SSH 公钥',
'gpuservice.instance.ssh.enable': '启用 SSH 访问',
'gpuservice.instance.ssh.assignKey': '分配 SSH 公钥',
@@ -98,6 +127,9 @@ export default {
'gpuservice.storage.persistentVolume.tips':
'数据在实例重启后仍会保留,仅在实例被终止时删除。无法被其它实例共享。',
'gpuservice.storage.persistentVolume.required': '请选择持久卷',
'gpuservice.storage.persistentVolume.capacity': '容量(GB',
'gpuservice.storage.persistentVolume.capacity.required': '请输入容量',
'gpuservice.storage.persistentVolume.releaseWithInstance': '随实例释放',
'gpuservice.storage.tempCapacity': '容量(GB',
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
'gpuservice.form.rule.name':
+1
View File
@@ -45,5 +45,6 @@ export default {
'menu.gpuService.instances': 'GPU 实例',
'menu.gpuService.templates': '实例模板',
'menu.gpuService.storage': '存储',
'menu.gpuService.storageTypes': '存储类型',
'menu.gpuService.publicKeys': 'SSH 公钥'
};
+3
View File
@@ -60,6 +60,9 @@ export default {
'noresult.gpuservice.storage.title': '暂无存储',
'noresult.gpuservice.storage.subTitle': '尚未添加任何存储。',
'noresult.gpuservice.storage.nofound': '未找到匹配的存储',
'noresult.gpuservice.storageType.title': '暂无存储类型',
'noresult.gpuservice.storageType.subTitle': '尚未添加任何存储类型。',
'noresult.gpuservice.storageType.nofound': '未找到匹配的存储类型',
'noresult.gpuservice.sshkey.title': '暂无 SSH 公钥',
'noresult.gpuservice.sshkey.subTitle': '尚未添加任何 SSH 公钥。',
'noresult.gpuservice.sshkey.nofound': '未找到匹配的 SSH 公钥'
@@ -20,11 +20,10 @@ interface NumberSelectionProps {
input?: React.CSSProperties;
};
labelExtra?: React.ReactNode;
maxCount?: number;
onChange?: (value: number) => void;
}
const PRESET_ITEMS = [1, 2, 4, 8];
const NumberSelection: React.FC<NumberSelectionProps> = ({
id,
step = 1,
@@ -36,21 +35,26 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
required,
labelExtra,
className,
maxCount = 8,
style,
onChange
}) => {
const intl = useIntl();
const showCustomInput = max > 8;
const showCustomInput = max > maxCount;
const presetItems = Array.from(
{ length: Math.max(0, maxCount) },
(_, i) => i + 1
);
const computedItems = showCustomInput
? PRESET_ITEMS.filter((n) => n >= min && n <= max)
? presetItems.filter((n) => n >= min && n <= max)
: Array.from(
{ length: Math.max(0, Math.floor((max - min) / step) + 1) },
(_, i) => min + i * step
);
const items = computedItems.length === 0 ? [0] : computedItems;
const [inputValue, setInputValue] = useState<number | null>(() =>
value !== undefined && value !== null && !PRESET_ITEMS.includes(value)
value !== undefined && value !== null && !presetItems.includes(value)
? value
: null
);
@@ -58,7 +62,7 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
useEffect(() => {
if (value === undefined || value === null) {
setInputValue(null);
} else if (PRESET_ITEMS.includes(value)) {
} else if (presetItems.includes(value)) {
setInputValue(null);
} else {
setInputValue(value);
@@ -83,7 +87,6 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
}
onChange?.(inputValue);
};
console.log('value+++', value, 'inputValue', items, inputValue);
return (
<div
id={id}
@@ -137,7 +140,7 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
})}
>
<InputNumber
controls
controls={false}
variant="borderless"
min={min}
max={max}
@@ -146,7 +149,7 @@ const NumberSelection: React.FC<NumberSelectionProps> = ({
value={inputValue || undefined}
style={{
fontSize: 14,
width: 140,
width: 60,
height: 32,
backgroundColor: 'var(--ant-color-bg-container)',
...style
@@ -0,0 +1,32 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { Input as CInput } from '@gpustack/core-ui';
import { Form } from 'antd';
import type { NamePath } from 'antd/es/form/interface';
import { useAtomValue } from 'jotai';
import { useEffect } from 'react';
interface OwnerPrincipalIdFieldProps {
name?: NamePath;
}
const OwnerPrincipalIdField: React.FC<OwnerPrincipalIdFieldProps> = ({
name = 'owner_principal_id'
}) => {
const currentCluster = useAtomValue(currentClusterAtom);
const ownerPrincipalId = currentCluster?.owner_principal_id;
const form = Form.useFormInstance();
useEffect(() => {
if (ownerPrincipalId != null) {
form.setFieldValue(name, ownerPrincipalId);
}
}, [ownerPrincipalId, name, form]);
return (
<Form.Item name={name} hidden>
<CInput.Input />
</Form.Item>
);
};
export default OwnerPrincipalIdField;
+48 -204
View File
@@ -2,6 +2,7 @@ import { request } from '@umijs/max';
import _ from 'lodash';
import { omitPathParams } from '../../utils';
import {
FormData,
InstanceEvents,
InstanceLog,
InstanceLogQueryParams,
@@ -9,176 +10,89 @@ import {
ListItem
} from '../config/types';
export const GPU_SERVICE_INSTANCES_API = (params: {
namespace: string;
clusterID?: number;
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances`;
export const GPU_SERVICE_INSTANCES_API = '/gpu-instances';
export const GPU_SERVICE_INSTANCES_TYPE_API = (params: {
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/instancetypes`;
};
export const GPU_SERVICE_INSTANCES_TYPE_API = '/gpu-instance-types';
// View logs / events still go through the K8s proxy until the /v2
// /gpu-instances API exposes equivalents. clusterID and namespace come
// per-row from `row.clusterId` and `row.status.namespace`.
export const GPU_SERVICE_INSTANCES_LOG_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/log`;
};
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/log`;
export const GPU_SERVICE_INSTANCES_EVENTS_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/events`;
};
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/events`;
export const GPU_SERVICE_INSTANCE_PV_EVENTS_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instancepersistentvolumes/${params.name}/events`;
};
// TODO: replace placeholder once start/stop endpoints are finalized
export const GPU_SERVICE_INSTANCE_START_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/start`;
};
// TODO: replace placeholder once start/stop endpoints are finalized
export const GPU_SERVICE_INSTANCE_STOP_API = (params: {
namespace: string;
name: string;
clusterID?: number;
}) => {
return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instances/${params.name}/stop`;
};
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instancepersistentvolumes/${params.name}/events`;
// =========== Instances ===========
export async function queryGPUServiceInstances(
params: Global.K8sSearchParams & {
namespace: string;
clusterID?: number;
},
params: Global.SearchParams,
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token
}
);
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_INSTANCES_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServiceInstance(
params: {
namespace: string;
clusterID?: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'POST',
data: params.data,
cancelToken: option?.token
}
);
export async function createGPUServiceInstance(params: { data: FormData }) {
return request<ListItem>(GPU_SERVICE_INSTANCES_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServiceInstance(
params: {
namespace: string;
clusterID?: number;
id: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
`${GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.data?.metadata?.name}`,
{
method: 'PUT',
data: params.data,
cancelToken: option?.token
}
);
export async function updateGPUServiceInstance(params: {
id: number;
data: Partial<FormData>;
}) {
return request<ListItem>(`${GPU_SERVICE_INSTANCES_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServiceInstance(
params: {
namespace: string;
clusterID?: number;
id: number;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
`${GPU_SERVICE_INSTANCES_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.id}`,
{
method: 'DELETE',
cancelToken: option?.token
}
);
export async function deleteGPUServiceInstance(id: number) {
return request(`${GPU_SERVICE_INSTANCES_API}/${id}`, {
method: 'DELETE'
});
}
// =========== Instance Types ===========
export async function queryGPUServiceInstanceTypes(
params: Global.K8sSearchParams & { clusterID?: number },
params: Global.SearchParams = { page: 1, perPage: 100 },
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<InstanceTypeItem>>(
GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID }),
return request<Global.PageResponse<InstanceTypeItem>>(
GPU_SERVICE_INSTANCES_TYPE_API,
{
method: 'GET',
params: omitPathParams(params),
params,
cancelToken: options?.token
}
);
}
// =========== Logs / Events (K8s proxy) ===========
export async function queryGPUServiceInstanceEvents(
params: {
namespace: string;
@@ -192,11 +106,11 @@ export async function queryGPUServiceInstanceEvents(
return;
}
return request<InstanceEvents>(
`${GPU_SERVICE_INSTANCES_EVENTS_API({
GPU_SERVICE_INSTANCES_EVENTS_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
})}`,
}),
{
method: 'GET',
params: omitPathParams(_.omit(params, ['name'])),
@@ -217,11 +131,11 @@ export async function queryGPUServiceInstancePVEvents(
return;
}
return request<InstanceEvents>(
`${GPU_SERVICE_INSTANCE_PV_EVENTS_API({
GPU_SERVICE_INSTANCE_PV_EVENTS_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
})}`,
}),
{
method: 'GET',
params: omitPathParams(_.omit(params, ['name'])),
@@ -230,56 +144,6 @@ export async function queryGPUServiceInstancePVEvents(
);
}
// TODO: replace placeholder once start/stop endpoints are finalized
export async function startGPUServiceInstance(
params: {
namespace: string;
name: string;
clusterID?: number;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
GPU_SERVICE_INSTANCE_START_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
}),
{
method: 'POST',
cancelToken: option?.token
}
);
}
// TODO: replace placeholder once start/stop endpoints are finalized
export async function stopGPUServiceInstance(
params: {
namespace: string;
name: string;
clusterID?: number;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request(
GPU_SERVICE_INSTANCE_STOP_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
}),
{
method: 'POST',
cancelToken: option?.token
}
);
}
export async function queryGPUServiceInstanceLog(
params: InstanceLogQueryParams & {
namespace: string;
@@ -292,11 +156,11 @@ export async function queryGPUServiceInstanceLog(
return;
}
return request<InstanceLog>(
`${GPU_SERVICE_INSTANCES_LOG_API({
GPU_SERVICE_INSTANCES_LOG_API({
namespace: params.namespace,
clusterID: params.clusterID,
name: params.name
})}`,
}),
{
method: 'GET',
params: omitPathParams(_.omit(params, ['name'])),
@@ -304,23 +168,3 @@ export async function queryGPUServiceInstanceLog(
}
);
}
export async function queryGPUServiceInstanceTypeItems(
params: {
name: string;
clusterID?: number;
},
options?: any
) {
if (!params.clusterID) {
return;
}
return request<InstanceTypeItem>(
`${GPU_SERVICE_INSTANCES_TYPE_API({ clusterID: params.clusterID })}/${params.name}`,
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token
}
);
}
@@ -101,7 +101,8 @@ const AddModal: React.FC<AddModalProps> = ({
const instanceTypeList = detailData?.items || [];
const templateList = templatesData?.items || [];
const readonly = action === PageAction.VIEW;
// const readonly = action === PageAction.VIEW;
const readonly = false;
const isRecreate = realAction === PageAction.CREATE;
const showResourceSelectors = action === PageAction.CREATE || isRecreate;
const shouldAutoSelectResource = action === PageAction.CREATE && !isRecreate;
@@ -119,27 +120,33 @@ const AddModal: React.FC<AddModalProps> = ({
instanceType: InstanceTypeItem,
template: TemplateItem | undefined
) => {
const name = instanceType.metadata?.name;
const manufacturer = instanceType.spec?.manufacturer;
setInstanceTypeSelection({ instanceType: name, manufacturer });
setInstanceTypeSelection({
instanceType: instanceType.name,
manufacturer
});
setTemplateId(template?.id);
const currentSpec = form.current?.getFieldsValue()?.spec || {};
const updatedSpec = {
...currentSpec,
type: name,
resources: { ...(currentSpec.resources || {}), accelerator: '1' }
};
if (template) {
const currentSpec = form.current?.getFieldsValue()?.spec || {};
form.current?.setFieldsValue({
manufacturer: template.manufacturer,
description: JSON.stringify(
{
name: instanceType.name,
spec: {
...instanceType.spec
}
},
null,
2
),
spec: {
...updatedSpec,
...currentSpec,
...template.spec,
resources: {
...(updatedSpec.resources || {}),
...(currentSpec.resources || {}),
...(template.spec?.resources || {})
}
}
@@ -147,16 +154,46 @@ const AddModal: React.FC<AddModalProps> = ({
} else {
form.current?.setFieldsValue({
manufacturer: undefined,
spec: updatedSpec
description: JSON.stringify(instanceType.spec, null, 2)
});
}
form.current?.applyInstanceType?.(instanceType);
};
const findAggregateOf = (
candidateName: string | undefined,
clusterId: number | null | undefined,
instanceTypes: InstanceTypeItem[]
): InstanceTypeItem | undefined => {
if (!candidateName) return undefined;
return instanceTypes.find((item) =>
(item.status?.acceleratorTiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
};
const applyAutoSelection = (
instanceTypes: InstanceTypeItem[],
templates: TemplateItem[]
) => {
if (!shouldAutoSelectResource) return;
// On edit / view, surface the persisted selection in the card list.
if (!shouldAutoSelectResource) {
const aggregate = findAggregateOf(
data?.spec?.type,
data?.clusterId,
instanceTypes
);
if (aggregate) {
setInstanceTypeSelection({
instanceType: aggregate.name,
manufacturer: aggregate.spec?.manufacturer
});
}
return;
}
if (!instanceTypes.length) return;
const first = instanceTypes[0];
@@ -178,18 +215,21 @@ const AddModal: React.FC<AddModalProps> = ({
return;
}
const session = ++sessionRef.current;
Promise.all([fetchData({}), fetchTemplates({ page: -1 })]).then(
([instanceRes, templatesRes]) => {
if (action === PageAction.CREATE) {
const session = ++sessionRef.current;
Promise.all([
fetchData({ page: 1, perPage: 100 }),
fetchTemplates({ page: -1 })
]).then(([instanceRes, templatesRes]) => {
if (sessionRef.current !== session) return;
applyAutoSelection(instanceRes?.items || [], templatesRes?.items || []);
}
);
}, [open, shouldAutoSelectResource]);
});
}
}, [open, shouldAutoSelectResource, action]);
// filter instance types
const filteredInstanceTypes = instanceTypeList.filter((item) =>
matchKeyword([item.metadata?.name], instanceKeyword)
matchKeyword([item.name], instanceKeyword)
);
// filter templates based on selection and keyword
@@ -370,7 +410,7 @@ const AddModal: React.FC<AddModalProps> = ({
}
>
<>
{action !== PageAction.VIEW && (
{action !== PageAction.EDIT && (
<ColTitle>
{intl.formatMessage({ id: 'common.title.config' })}
</ColTitle>
@@ -1,16 +1,12 @@
import { AutoTooltip, IconFont, StatusTag, ThemeTag } from '@gpustack/core-ui';
import { AutoTooltip, IconFont, ThemeTag } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex } from 'antd';
import styled from 'styled-components';
import { manufactureColorMap } from '../../templates/config';
import {
convertKiToGi,
InstanceTypePhaseLabelMap,
InstanceTypePhaseStatus
} from '../config';
import { convertKiToGi } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
const toDisplayUnit = (value?: string) =>
const toDisplayUnit = (value?: string | null) =>
value ? value.replace(/Gi$/, 'GB').replace(/Ti$/, 'TB') : value;
const Title = styled.div`
@@ -60,24 +56,48 @@ interface InstanceTypeItemProps {
showStatus?: boolean;
}
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
item,
showStatus = true
}) => {
const intl = useIntl();
const name = item.metadata?.name;
const acceleratable = item.spec?.acceleratable;
const MetaItem: React.FC<{
icon: string;
label: string;
value?: string | null;
showDot?: boolean;
show?: boolean;
}> = ({ icon, label, value, showDot = true, show = true }) => {
if (!show) return null;
return (
<>
{showDot && <span className="dot"></span>}
<span className="meta-item">
<IconFont type={icon} className="meta-icon" />
<Flex align="center" gap={4}>
<span>{label}</span>
<span>{value || '-'}</span>
</Flex>
</span>
</>
);
};
const manufacturerKey = item.spec?.manufacturer;
const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({ item }) => {
const intl = useIntl();
const name = item.name;
const specData = item.spec || {};
const acceleratable = specData.acceleratable;
const manufacturerKey = specData.manufacturer || (acceleratable ? '' : 'cpu');
const manufacturer = manufacturerKey?.toUpperCase();
const manufacturerColor =
(manufacturerKey && manufactureColorMap[manufacturerKey]) ?? 'purple';
// resource remaining status
const remainingData = item.status?.remaining || {};
const renderName = () => {
const product = item.spec?.product;
const product = specData.product;
const cpuCapacity = remainingData.cpu;
const displayName =
product ||
(!acceleratable ? `${item.status?.cpu?.capacity} vCPUs` : name);
(!acceleratable && cpuCapacity ? `${cpuCapacity} vCPUs` : name);
return displayName;
};
@@ -85,8 +105,8 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
<>
<Title>
<Flex gap={8} align="center">
<AutoTooltip ghost minWidth={20} maxWidth={180}>
{item.spec ? renderName() : '-'}
<AutoTooltip ghost minWidth={20} maxWidth={200}>
{renderName() || '-'}
</AutoTooltip>
{acceleratable && manufacturer && (
<span
@@ -95,99 +115,59 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
fontWeight: 400
}}
>
<ThemeTag color={manufacturerColor}>{manufacturer}</ThemeTag>
<ThemeTag color={manufacturerColor} disabled={false}>
{manufacturer}
</ThemeTag>
</span>
)}
</Flex>
{showStatus && (
<Flex gap={8} align="center">
{item.status?.phase && (
<StatusTag
statusValue={{
status:
InstanceTypePhaseStatus[item.status.phase] ?? 'inactive',
text:
InstanceTypePhaseLabelMap[item.status.phase] ??
item.status.phase,
message: ''
}}
/>
)}
</Flex>
)}
</Title>
<Meta>
<span style={{ display: 'flex', height: 15 }}>
{acceleratable && (
<span className="meta-row">
<span className="meta-item">
<IconFont type="icon-gpu1" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.memory' })}
</span>
<span>
{toDisplayUnit(convertKiToGi(item.spec?.memory)) ?? '-'}
</span>
</Flex>
</span>
{item.spec?.sliced && (
<>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-sliced" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({
id: 'gpuservice.instance.sliced'
})}
</span>
<span>{item.spec?.sliced ?? '-'}</span>
</Flex>
</span>
</>
)}
{item.status?.accelerator?.remaining && (
<>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-database" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({
id: 'gpuservice.instance.stock'
})}
</span>
<span>{item.status?.accelerator?.remaining ?? '-'}</span>
</Flex>
</span>
</>
)}
<MetaItem
showDot={false}
icon="icon-gpu1"
label={intl.formatMessage({ id: 'gpuservice.instance.memory' })}
value={
toDisplayUnit(convertKiToGi(specData?.memory ?? undefined)) ??
'-'
}
></MetaItem>
<MetaItem
show={!!specData?.sliced}
icon="icon-sliced"
label={intl.formatMessage({
id: 'gpuservice.instance.sliced'
})}
value={specData?.sliced}
></MetaItem>
<MetaItem
show={!!remainingData.accelerator}
icon="icon-database"
label={intl.formatMessage({
id: 'gpuservice.instance.stock'
})}
value={remainingData.accelerator}
></MetaItem>
</span>
)}
</span>
<span className="meta-row">
<span className="meta-item">
<IconFont type="icon-ram-02" className="meta-icon" />
<Flex align="center" gap={4}>
<span>
{intl.formatMessage({ id: 'gpuservice.instance.ram' })}
</span>
<span>{toDisplayUnit(item.status?.ram?.capacity) ?? '-'}</span>
</Flex>
</span>
{acceleratable && (
<>
<span className="dot"></span>
<span className="meta-item">
<IconFont type="icon-cpu" className="meta-icon" />
<Flex align="center" gap={4}>
<span>vCPU</span>
<span>{item.status?.cpu?.capacity ?? '-'}</span>
</Flex>
</span>
</>
)}
<MetaItem
showDot={false}
icon="icon-ram-02"
label={intl.formatMessage({ id: 'gpuservice.instance.ram' })}
value={toDisplayUnit(convertKiToGi(remainingData.ram)) ?? '-'}
></MetaItem>
<MetaItem
show={acceleratable}
showDot={true}
icon="icon-cpu"
label="vCPU"
value={remainingData.cpu ?? '-'}
></MetaItem>
</span>
</Meta>
</>
@@ -3,7 +3,7 @@ import { TemplateCard } from '@gpustack/core-ui';
import { Empty, Spin } from 'antd';
import _ from 'lodash';
import styled from 'styled-components';
import { InstanceTypePhaseValueMap } from '../config';
import { getAcceleratorMax } from '../config';
import { InstanceTypeItem as InstanceTypeItemModel } from '../config/types';
import InstanceTypeItem from './instance-type-item';
@@ -20,8 +20,12 @@ interface InstanceTypeListProps {
loading?: boolean;
}
const isAvailable = (item: InstanceTypeItemModel) =>
item.status?.phase === InstanceTypePhaseValueMap.Active;
// CPU types always have stock (no accelerator). Accelerated types are
// considered available when at least one tier offers onceMaxRequest > 0.
const isAvailable = (item: InstanceTypeItemModel) => {
if (!item.spec?.acceleratable) return true;
return getAcceleratorMax(item.status?.acceleratorTiers) > 0;
};
const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
value,
@@ -53,7 +57,7 @@ const InstanceTypeList: React.FC<InstanceTypeListProps> = ({
<TypeGrid>
{dataList.map((item) => {
const disabled = !isAvailable(item);
const name = item.metadata?.name;
const name = item.name;
return (
<TemplateCard
key={name}
@@ -31,7 +31,7 @@ const eventTypeStatus: Record<string, 'success' | 'warning' | 'error'> = {
const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const intl = useIntl();
const { open, onCancel, name } = props || {};
const { open, onCancel, name, namespace, clusterID } = props || {};
const [activeKey, setActiveKey] = useState('instance');
const {
@@ -52,9 +52,9 @@ const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const volumeEvents = volumeEventsData?.items ?? [];
const refreshAll = () => {
if (!name) return;
fetchInstanceEvents({ name });
fetchVolumeEvents({ name });
if (!name || !namespace || !clusterID) return;
fetchInstanceEvents({ name, namespace, clusterID });
fetchVolumeEvents({ name, namespace, clusterID });
};
useEffect(() => {
@@ -0,0 +1,14 @@
import { PageActionType } from '@/config/types';
import { createContext } from 'react';
export interface FormContextValue {
action: PageActionType;
currentData: any;
}
export const FormContext = createContext<FormContextValue>({
action: 'create',
currentData: null
});
export const FormContextProvider = FormContext.Provider;
+79 -78
View File
@@ -3,11 +3,6 @@ import { StatusType } from '@/config/types';
import { IconFont, icons } from '@gpustack/core-ui';
import _ from 'lodash';
import React from 'react';
import {
InstanceTypeItem,
InstanceTypeResource,
InstanceTypeStatus
} from './types';
export const InstanceStatusValueMap = {
Scheduling: 'Scheduling',
@@ -45,13 +40,22 @@ export const status: Record<string, StatusType> = {
[InstanceStatusValueMap.Ready]: StatusMaps.success
};
// Lifecycle actions (logs/events/start/stop) require K8s proxy endpoints that
// the new /v2/gpu-instances API does not yet expose; keep them visible but
// disabled until backend support lands.
export const rowActionList = [
{
label: 'common.button.view',
key: 'view',
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.DetailInfo
icon: icons.EditOutlined
},
// {
// label: 'common.button.view',
// key: 'view',
// locale: true,
// icon: icons.DetailInfo
// },
{
label: 'common.button.viewlog',
key: 'viewlog',
@@ -64,30 +68,30 @@ export const rowActionList = [
locale: true,
icon: icons.ProfileOutlined
},
{
label: 'common.button.start',
key: 'start',
locale: true,
icon: icons.Play
},
{
label: 'common.button.stop',
key: 'stop',
locale: true,
icon: icons.Stop
},
// {
// label: 'common.button.edit',
// key: 'edit',
// label: 'common.button.start',
// key: 'start',
// locale: true,
// icon: icons.EditOutlined
// icon: icons.Play,
// props: {
// disabled: true
// }
// },
// {
// label: 'common.button.stop',
// key: 'stop',
// locale: true,
// icon: icons.Stop,
// props: {
// disabled: true
// }
// },
// {
// label: 'common.button.recreate',
// key: 'recreate',
// locale: true,
// icon: icons.RetweetOutlined
// },
{
label: 'common.button.recreate',
key: 'recreate',
locale: true,
icon: icons.RetweetOutlined
},
{
label: 'common.button.delete',
key: 'delete',
@@ -103,12 +107,18 @@ export const batchActionList = [
{
label: 'common.button.start',
key: 'start',
icon: icons.Play
icon: icons.Play,
props: {
disabled: true
}
},
{
label: 'common.button.stop',
key: 'stop',
icon: icons.Stop
icon: icons.Stop,
props: {
disabled: true
}
},
{
label: 'common.button.delete',
@@ -121,8 +131,6 @@ export const batchActionList = [
];
export const InstanceTypePhaseValueMap = {
// Available: 'Available',
// Unavailable: 'Unavailable'
PreParing: 'Preparing',
Inactive: 'Inactive',
Active: 'Active'
@@ -141,10 +149,12 @@ export const InstanceTypePhaseStatus: Record<string, StatusType> = {
};
export const StorageModeValueMap = {
Existing: 'existing',
Temporary: 'temporary'
Temporary: 'temporary',
Persistent: 'persistent'
};
export const DEFAULT_PV_CAPACITY_GB = 20;
// Constant SSH public key resource name used when SSH is enabled
export const DEFAULT_SSH_PUBLIC_KEY_NAME = 'default';
@@ -163,49 +173,40 @@ export const convertKiToGi = (value?: string): string | undefined => {
return `${_.round(Number(num) / GI_DIVISOR[unit], 2)}Gi`;
};
export const transformInstanceTypeResource = (
resource?: InstanceTypeResource
): InstanceTypeResource | undefined => {
if (!resource) return resource;
return {
...resource,
capacity: convertKiToGi(
resource.capacity
) as InstanceTypeResource['capacity'],
onceMaxRequest: convertKiToGi(
resource.onceMaxRequest
) as InstanceTypeResource['onceMaxRequest'],
remaining: convertKiToGi(
resource.remaining
) as InstanceTypeResource['remaining']
};
const parseQuantity = (value?: string | null): number => {
if (!value) return 0;
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
return match ? Number(match[1]) || 0 : 0;
};
export const transformInstanceType = (
item: InstanceTypeItem
): InstanceTypeItem => {
if (!item?.status) return item;
const status = item.status;
return {
...item,
spec: {
...item.spec,
manufacturer: !item.spec?.acceleratable ? 'cpu' : item.spec.manufacturer
},
status: {
...status,
accelerator: transformInstanceTypeResource(
status.accelerator
) as InstanceTypeStatus['accelerator'],
cpu: transformInstanceTypeResource(
status.cpu
) as InstanceTypeStatus['cpu'],
localStorage: transformInstanceTypeResource(
status.localStorage
) as InstanceTypeStatus['localStorage'],
ram: transformInstanceTypeResource(
status.ram
) as InstanceTypeStatus['ram']
}
};
// Returns the slider max for the accelerator count: the largest
// tier.onceMaxRequest across all acceleratorTiers (not from candidates).
export const getAcceleratorMax = (
tiers?: { onceMaxRequest: string }[] | null
) => {
if (!tiers?.length) return 0;
return tiers.reduce((acc, tier) => {
const n = parseQuantity(tier.onceMaxRequest);
return n > acc ? n : acc;
}, 0);
};
// Picks the candidate (cluster + type name) that should fulfill a requested
// accelerator count: the first candidate of the smallest tier whose
// onceMaxRequest is >= the requested count.
export const pickCandidateForAccelerator = <
C extends { cluster: string; name: string }
>(
tiers:
| { onceMaxRequest: string; candidates?: C[] | null }[]
| undefined
| null,
count: number
): C | null => {
if (!tiers?.length) return null;
const sorted = [...tiers].sort(
(a, b) => parseQuantity(a.onceMaxRequest) - parseQuantity(b.onceMaxRequest)
);
const tier = sorted.find((t) => parseQuantity(t.onceMaxRequest) >= count);
return tier?.candidates?.[0] ?? null;
};
+90 -116
View File
@@ -2,16 +2,16 @@ export type ImagePullPolicy = 'Always' | 'IfNotPresent' | 'Never';
// instance form data
export interface FormData {
metadata: {
name: string;
namespace: string;
};
name: string;
clusterId?: number | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
enable_ssh?: boolean;
spec: {
type: string;
image: string;
imagePullPolicy: ImagePullPolicy;
displayName: string;
command: string[];
ports: {
port: number;
@@ -27,9 +27,11 @@ export interface FormData {
cpu?: string;
ram?: string;
localStorage?: string;
accelerator?: string;
// Stored in the form as a number so NumberSelection's strict
// equality picks up the active item. Serialized to a string at the
// API boundary in handleFinish.
accelerator?: number | string;
};
description: string;
volume: {
ephemeral?: {
capacity?: string;
@@ -37,162 +39,136 @@ export interface FormData {
persistent?: {
name: string;
};
persistentTemplate?: {
name?: string;
spec: {
type: string;
capacity: string;
};
releaseWithInstance?: boolean;
};
};
sshPublicKey: {
name: string;
};
sshPublicKeys?: { name: string }[];
};
}
export type InstanceServicePortProtocol = 'TCP' | 'UDP';
export interface InstanceIP {
ip: string;
}
export interface InstanceServicePort {
port: number;
name: string;
nodePort?: number;
protocol?: InstanceServicePortProtocol;
}
export interface InstanceStatus {
hostIPs?: {
ip: string;
}[];
namespace?: string;
phase?: string;
phaseMessage?: string;
podIPs?: {
ip: string;
}[];
ports?: {
port: number;
name: string;
nodePort?: number;
protocol?: InstanceServicePortProtocol;
}[];
hostIPs?: InstanceIP[];
podIPs?: InstanceIP[];
ports?: InstanceServicePort[];
}
// instance list item
export interface ListItem extends Omit<FormData, 'metadata'> {
export interface ListItem extends FormData {
id: number;
status?: InstanceStatus;
metadata: {
name: string;
namespace?: string;
uid: string;
resourceVersion: string;
creationTimestamp: string;
annotations?: Record<string, string>;
managedFields?: {
manager: string;
operation: string;
apiVersion: string;
time: string;
fieldsType: string;
fieldsV1: Record<string, any>;
}[];
};
created_at: string;
updated_at: string;
deleted_at?: string | null;
status?: InstanceStatus | null;
}
type Quality =
| `${number}`
| `${number}Ki`
| `${number}Mi`
| `${number}Gi`
| `${number}Ti`;
// =========== Instance Types ===========
export interface InstanceTypeResource {
capacity?: Quality;
onceMaxRequest?: Quality;
remaining?: Quality;
onceMaxRequest: string;
remaining: string;
capacity: string;
}
export interface InstanceTypeStatus {
export interface InstanceTypeCandidate {
cluster: string;
name: string;
accelerator: InstanceTypeResource;
cpu: InstanceTypeResource;
localStorage: InstanceTypeResource;
phase: string;
phaseMessage?: string;
ram: InstanceTypeResource;
localStorage: InstanceTypeResource;
}
export interface InstanceTypeMetadata {
name: string;
namespace?: string;
uid?: string;
resourceVersion?: string;
creationTimestamp?: string;
generateName?: string;
generation?: number;
finalizers?: string[];
export interface InstanceTypeTier {
onceMaxRequest: string;
candidates?: InstanceTypeCandidate[] | null;
}
export interface InstanceTypeRemainingResource {
accelerator?: string | null;
cpu: string;
ram: string;
localStorage: string;
}
export interface InstanceTypeSpec {
acceleratable: boolean;
group: string;
computeCapability?: string;
family?: string;
manufacturer?: string;
memory?: string;
product?: string;
sliced?: number;
acceleratable: boolean;
manufacturer: string;
product?: string | null;
memory?: string | null;
family?: string | null;
computeCapability?: string | null;
sliced?: string | null;
}
export interface InstanceTypeStatus {
remaining: InstanceTypeRemainingResource;
acceleratorTiers?: InstanceTypeTier[] | null;
}
export interface InstanceTypeItem {
apiVersion: string;
kind: string;
id: number;
metadata: InstanceTypeMetadata;
name: string;
spec: InstanceTypeSpec;
status: InstanceTypeStatus;
}
export interface InstanceEventObjectReference {
apiVersion?: string;
fieldPath?: string;
kind?: string;
name?: string;
namespace?: string;
resourceVersion?: string;
uid?: string;
}
export interface InstanceEventSource {
component?: string;
host?: string;
}
export interface InstanceEventSeries {
count?: number;
lastObservedTime?: string;
}
// =========== Events / logs (placeholder) ===========
// The /v2/gpu-instances API does not yet expose log/event endpoints. These
// shapes are retained as minimal placeholders so disabled UI continues to
// compile until backend support lands.
export interface InstanceEventItem {
action?: string;
apiVersion?: string;
type?: string;
reason?: string;
message?: string;
count?: number;
eventTime?: string;
firstTimestamp?: string;
involvedObject: InstanceEventObjectReference;
kind?: string;
lastTimestamp?: string;
message?: string;
metadata: {
name?: string;
namespace?: string;
uid?: string;
resourceVersion?: string;
creationTimestamp?: string;
};
reason?: string;
related?: InstanceEventObjectReference;
reportingComponent?: string;
reportingInstance?: string;
series?: InstanceEventSeries;
source?: InstanceEventSource;
type?: string;
source?: {
component?: string;
host?: string;
};
series?: {
count?: number;
lastObservedTime?: string;
};
metadata?: {
name?: string;
namespace?: string;
};
}
export interface InstanceEvents {
apiVersion?: string;
kind?: string;
metadata?: {
resourceVersion?: string;
};
items: InstanceEventItem[];
}
export type InstanceLog = string;
export interface InstanceLogQueryParams {
follow?: boolean;
limitBytes?: number;
@@ -201,5 +177,3 @@ export interface InstanceLogQueryParams {
timestamps?: boolean;
pretty?: string;
}
export type InstanceLog = string;
@@ -3,6 +3,7 @@ import { PageActionType } from '@/config/types';
import { Input as CInput } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
import formStyles from '../styles/instances.module.less';
@@ -16,9 +17,10 @@ const Basic = ({
const intl = useIntl();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
data-field="name"
name={['metadata', 'name']}
name="name"
rules={[
{
required: true,
@@ -38,10 +40,17 @@ const Basic = ({
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
disabled={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<div className={formStyles.command}>
<Form.Item<FormData> name={['spec', 'description']}>
<Form.Item<FormData> name="description" hidden>
<CInput.TextArea
disabled={disabled}
disabled={false}
label={intl.formatMessage({ id: 'common.table.description' })}
scaleSize
/>
+188 -53
View File
@@ -4,6 +4,7 @@ import { PageActionType } from '@/config/types';
import { PlusOutlined } from '@ant-design/icons';
import {
CheckboxField,
Input as CInput,
CollapsePanel,
IconFont,
MultipleSelect,
@@ -21,13 +22,15 @@ import {
useEffect,
useImperativeHandle,
useMemo,
useRef
useRef,
useState
} from 'react';
import useGetSshkey from '../../public-keys/services/use-get-sshkey';
import useQuerySshkeys from '../../public-keys/services/use-query-sshkeys';
import { DefaultImagePullPolicy } from '../../templates/config';
import TemplateBasicForm, {
BasicResourceMax
} from '../../templates/forms/basic';
import { pickCandidateForAccelerator } from '../config';
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
import instanceStyles from '../styles/instances.module.less';
import Basic from './basic';
@@ -53,7 +56,7 @@ interface InstanceFormProps {
onFinish: (values: FormData) => Promise<void>;
}
const parseQuantityToNumber = (value?: string): number | null => {
const parseQuantityToNumber = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
if (!match) return null;
@@ -61,6 +64,27 @@ const parseQuantityToNumber = (value?: string): number | null => {
return Number.isFinite(num) && num > 0 ? num : null;
};
// Converts a K8s-style quantity ("24314504Ki", "2Ti", "10Gi", or a bare
// number assumed to be Gi) to an integer GiB. memory and localStorage
// inputs are displayed in GB, so the max bound and the "Remaining {count}
// GB" label need the converted value.
const GI_FROM_UNIT: Record<string, number> = {
Ki: 1 / (1024 * 1024),
Mi: 1 / 1024,
Gi: 1,
Ti: 1024
};
const parseQuantityToGi = (value?: string | null): number | null => {
if (!value) return null;
const match = /^(-?\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/.exec(String(value));
if (!match) return null;
const num = Number(match[1]);
if (!Number.isFinite(num) || num <= 0) return null;
const factor = match[2] ? GI_FROM_UNIT[match[2]] : 1;
return Math.floor(num * factor);
};
const TABKeysMap = {
BASIC: 'basic',
INSTANCE_TYPE: 'instanceType',
@@ -71,7 +95,7 @@ const TABKeysMap = {
const requiredFields = {
[TABKeysMap.BASIC]: {
sort: 1,
fields: ['metadata.name']
fields: ['name']
},
[TABKeysMap.INSTANCE_TYPE]: {
sort: 2,
@@ -95,7 +119,6 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
currentData,
disabled,
open,
namespace = 'default',
instanceTypeList = [],
onFinish
} = props;
@@ -108,7 +131,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const formAction =
realAction === PageAction.CREATE ? PageAction.CREATE : action;
const sshEnabled = Form.useWatch('enable_ssh', form);
const { detailData: sshKeyData, fetchData: fetchSSHData } = useGetSshkey();
const { sshkeyOptions, fetchData: fetchSSHData } = useQuerySshkeys();
const { getScrollElementScrollableHeight } = useWrapperContext();
const {
activeKey,
@@ -127,25 +150,25 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
useEffect(() => {
if (open) {
fetchSSHData({});
fetchSSHData({ page: 1, perPage: 100 });
}
}, [open, action]);
const handleSSHEnableChange = (e: any) => {
if (!e?.target?.checked) {
return;
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const hasSSHPort = useMemo(
() =>
ports.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
),
[ports]
);
useEffect(() => {
if (hasSSHPort && !form.getFieldValue('enable_ssh')) {
form.setFieldValue('enable_ssh', true);
}
const currentPorts = form.getFieldValue(['spec', 'ports']) || [];
const hasSSHPort = currentPorts.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
);
if (!hasSSHPort) {
form.setFieldValue(
['spec', 'ports'],
[...currentPorts, { protocol: 'TCP', port: SSH_PORT, name: 'SSH' }]
);
}
};
}, [hasSSHPort, form]);
const segmentOptions = useMemo(
() => [
@@ -183,25 +206,50 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
[intl]
);
const ports = Form.useWatch(['spec', 'ports'], form) || [];
const [selectedInstanceType, setSelectedInstanceType] = useState<
InstanceTypeItem | undefined
>(undefined);
const [onceMaxRequest, setOnceMaxRequest] = useState<BasicResourceMax>({
cpu: null,
memory: null,
localStorage: null
});
const selectedTypeName = Form.useWatch(['spec', 'type'], form) as
| string
| undefined;
const onceMaxRequest = useMemo<BasicResourceMax>(() => {
const selected = instanceTypeList.find(
(item) => (item.metadata?.name || '') === selectedTypeName
const resolveAndApply = (
instanceType: InstanceTypeItem | undefined,
count: number,
options: { writeAccelerator?: boolean } = {}
) => {
if (!instanceType) {
setSelectedInstanceType(undefined);
setOnceMaxRequest({ cpu: null, memory: null, localStorage: null });
return;
}
const candidate = pickCandidateForAccelerator(
instanceType.status?.acceleratorTiers,
count
);
const status = selected?.status;
return {
cpu: parseQuantityToNumber(status?.cpu?.onceMaxRequest),
memory: parseQuantityToNumber(status?.ram?.onceMaxRequest),
localStorage: parseQuantityToNumber(
status?.localStorage?.onceMaxRequest
)
};
}, [instanceTypeList, selectedTypeName]);
setSelectedInstanceType(instanceType);
setOnceMaxRequest({
cpu: parseQuantityToNumber(candidate?.cpu?.onceMaxRequest),
memory: parseQuantityToGi(candidate?.ram?.onceMaxRequest),
localStorage: parseQuantityToGi(candidate?.localStorage?.onceMaxRequest)
});
form.setFieldsValue({
clusterId: candidate?.cluster ? Number(candidate.cluster) : null,
spec: {
type: candidate?.name || '',
...(options.writeAccelerator
? { resources: { accelerator: count } }
: {})
} as any
});
};
const handleAcceleratorChange = (count: number) => {
resolveAndApply(selectedInstanceType, count);
};
const onTargetChange = (key: string) => {
scrollTabsRef.current?.handleTargetChange(key);
@@ -235,34 +283,110 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
action === PageAction.VIEW ||
realAction === PageAction.CREATE)
) {
// The server returns `accelerator` as a string; coerce to a number
// so NumberSelection's strict equality picks up the active item.
const persistedAccelerator = currentData.spec?.resources?.accelerator;
const acceleratorAsNumber =
persistedAccelerator != null && persistedAccelerator !== ''
? Number(persistedAccelerator)
: undefined;
form.setFieldsValue({
metadata: {
name: currentData.metadata?.name,
namespace: currentData.metadata?.namespace
},
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
clusterId: currentData.clusterId,
spec: {
...currentData.spec,
imagePullPolicy:
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy
},
enable_ssh: !!currentData.spec?.sshPublicKey?.name
currentData.spec?.imagePullPolicy || DefaultImagePullPolicy,
resources: {
...currentData.spec?.resources,
accelerator: acceleratorAsNumber
},
sshPublicKeys:
currentData.spec?.sshPublicKeys?.map((k) => k.name) ?? []
} as any,
enable_ssh: !!currentData.spec?.sshPublicKeys?.length
});
const candidateName = currentData.spec?.type;
const clusterId = currentData.clusterId;
const aggregate = instanceTypeList.find((item) =>
(item.status?.acceleratorTiers ?? []).some((tier) =>
(tier.candidates ?? []).some(
(c) => c.name === candidateName && Number(c.cluster) === clusterId
)
)
);
if (aggregate) {
const count =
Number(currentData.spec?.resources?.accelerator) ||
(aggregate.spec?.acceleratable ? 1 : 0);
// No accelerator rewrite — the form already has the persisted value.
resolveAndApply(aggregate, count);
}
return;
}
}, [action, currentData, form, open, namespace, realAction]);
}, [action, currentData, form, open, realAction, instanceTypeList]);
const handleFinish = async (values: InstanceFormValues) => {
const selectedKeys = (values.spec as any)?.sshPublicKeys as
| string[]
| undefined;
const volume = values.spec?.volume ?? {};
const normalizedVolume = volume.persistentTemplate
? {
persistentTemplate: {
...volume.persistentTemplate,
name: volume.persistentTemplate.name || values.name || ''
}
}
: volume.persistent
? { persistent: volume.persistent }
: volume.ephemeral
? { ephemeral: volume.ephemeral }
: {};
// `spec.type` and `clusterId` are kept in sync with the resolved
// candidate via `resolveAndApply`, so submission is a straight pass
// -through. The API expects `accelerator` as a string per the schema.
const acceleratorValue = values.spec?.resources?.accelerator;
const normalizedResources = {
...values.spec?.resources,
accelerator:
acceleratorValue != null && acceleratorValue !== ''
? String(acceleratorValue)
: undefined
};
const submittedPorts = [...(values.spec?.ports ?? [])];
const submittedHasSSHPort = submittedPorts.some(
(item: any) => item?.protocol === 'TCP' && item?.port === SSH_PORT
);
if (values.enable_ssh && !submittedHasSSHPort) {
submittedPorts.push({
protocol: 'TCP',
port: SSH_PORT,
name: 'SSH'
});
}
await onFinish({
...values,
spec: {
...values.spec,
sshPublicKey: { name: sshKeyData?.name }
ports: submittedPorts,
resources: normalizedResources,
volume: normalizedVolume,
sshPublicKeys: values.enable_ssh
? (selectedKeys ?? []).map((name) => ({ name }))
: []
}
});
};
const segmentedTop =
action === PageAction.VIEW
action === PageAction.EDIT
? {
top: 0,
offsetTop: 100
@@ -282,7 +406,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
setFieldsValue: (values: Partial<InstanceFormValues>) => {
form.setFieldsValue(values as any);
},
getFieldsValue: () => form.getFieldsValue()
getFieldsValue: () => form.getFieldsValue(),
applyInstanceType: (instanceType: InstanceTypeItem) => {
const count = instanceType.spec?.acceleratable ? 1 : 0;
resolveAndApply(instanceType, count, { writeAccelerator: true });
}
}));
const handleAddSSHKey = () => {
@@ -319,7 +447,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
env: [],
volumeMount: '',
resources: {
accelerator: '1'
accelerator: 1
},
volume: {
ephemeral: {
@@ -334,6 +462,11 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}}
>
<Basic action={formAction} disabled={disabled} />
{/* Hidden form field: the candidate.cluster resolved from the
selected aggregate + accelerator count. */}
<Form.Item name="clusterId" hidden>
<CInput.Input />
</Form.Item>
<CollapsePanel
activeKey={collapseKeys}
accordion={false}
@@ -349,6 +482,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
<InstanceTypeFormItem
action={formAction}
disabled={disabled}
selectedInstanceType={selectedInstanceType}
currentData={currentData as any}
onGPUCountChange={handleAcceleratorChange}
/>
)
},
@@ -392,13 +528,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
label={intl.formatMessage({
id: 'gpuservice.instance.ssh.enable'
})}
onChange={handleSSHEnableChange}
></CheckboxField>
</Form.Item>
{sshEnabled && (
<div className={instanceStyles.sshkeySelection}>
<Form.Item<FormData>
name={['spec', 'sshPublicKey', 'name']}
name={['spec', 'sshPublicKeys']}
style={{
marginBottom: 12
}}
@@ -425,7 +560,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
width: '100%'
}
}}
options={[]}
options={sshkeyOptions}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
@@ -2,16 +2,17 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import NumberSelection from '@/pages/_components/number-selection';
import { useIntl } from '@umijs/max';
import { Alert, Form } from 'antd';
import { useEffect, useMemo } from 'react';
import { Alert, Flex, Form } from 'antd';
import _ from 'lodash';
import { useMemo } from 'react';
import styled from 'styled-components';
import InstanceTypeItem from '../components/instance-type-item';
import { InstanceTypePhaseValueMap } from '../config';
import { getAcceleratorMax } from '../config';
import {
FormData,
InstanceTypeItem as InstanceTypeItemModel
InstanceTypeItem as InstanceTypeItemModel,
ListItem
} from '../config/types';
import useQueryInstanceTypes from '../services/use-query-instance-types';
const FieldBlock = styled.div`
margin-bottom: 24px;
@@ -25,24 +26,22 @@ const SelectedCard = styled.div`
`;
interface InstanceTypePickerProps {
value?: string;
dataList: InstanceTypeItemModel[];
selectedInstanceType?: InstanceTypeItemModel;
}
const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
value,
dataList
selectedInstanceType
}) => {
const selected = useMemo(() => {
return (
dataList.find((item) => (item.metadata?.name || '') === value) ||
({} as InstanceTypeItemModel)
);
}, [value, dataList]);
const intl = useIntl();
return (
<SelectedCard>
<InstanceTypeItem item={selected} showStatus={false} />
{selectedInstanceType ? (
<InstanceTypeItem item={selectedInstanceType} showStatus={false} />
) : (
<span style={{ color: 'var(--ant-color-text-tertiary)' }}>
{intl.formatMessage({ id: 'gpuservice.instance.type.required' })}
</span>
)}
</SelectedCard>
);
};
@@ -50,56 +49,62 @@ const InstanceTypePicker: React.FC<InstanceTypePickerProps> = ({
interface InstanceTypeFormItemProps {
action: PageActionType;
disabled?: boolean;
currentData?: ListItem;
selectedInstanceType?: InstanceTypeItemModel;
onGPUCountChange?: (value: number) => void;
}
const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
action,
disabled
disabled,
currentData,
selectedInstanceType,
onGPUCountChange
}) => {
const intl = useIntl();
const form = Form.useFormInstance<FormData>();
const typeName = Form.useWatch(['spec', 'type'], form) as string | undefined;
const { detailData, fetchData } = useQueryInstanceTypes();
const maxGpuCount =
action === PageAction.EDIT
? _.toNumber(currentData?.spec?.resources?.accelerator) || 0
: getAcceleratorMax(selectedInstanceType?.status?.acceleratorTiers);
useEffect(() => {
fetchData({});
}, [fetchData]);
const handleOnGPUCountChange = (value: number) => {
onGPUCountChange?.(value);
};
const dataList = detailData?.items || [];
const selectedInstanceType = useMemo(() => {
return dataList.find((item) => (item.metadata?.name || '') === typeName);
}, [dataList, typeName]);
const maxGpuCount = useMemo(() => {
const onceMaxRequest =
selectedInstanceType?.status?.accelerator?.onceMaxRequest;
const num = onceMaxRequest ? Number(onceMaxRequest) : 0;
return num;
}, [selectedInstanceType]);
useEffect(() => {
if (!typeName && dataList.length > 0) {
const defaultType = dataList.find(
(item) => item.status?.phase === InstanceTypePhaseValueMap.Active
);
if (defaultType) {
form.setFieldValue(['spec', 'type'], defaultType.metadata?.name || '');
}
return;
const showGPUCount = useMemo(() => {
if (action === PageAction.EDIT) {
return _.toNumber(currentData?.spec?.resources?.accelerator) > 0;
}
return selectedInstanceType?.spec?.acceleratable;
}, [selectedInstanceType, action]);
const currentAccelerator =
Number(form.getFieldValue(['spec', 'resources', 'accelerator'])) || 1;
if (currentAccelerator > maxGpuCount) {
form.setFieldValue(['spec', 'resources', 'accelerator'], maxGpuCount);
const selectedInstanceData = useMemo(() => {
if (action === PageAction.EDIT) {
return JSON.parse(currentData?.description || '{}') || {};
}
return selectedInstanceType;
}, [selectedInstanceType, action, currentData]);
if (currentAccelerator < 1) {
form.setFieldValue(['spec', 'resources', 'accelerator'], 1);
}
}, [form, typeName, maxGpuCount, dataList]);
const renderInstanceType = () => {
const description = JSON.parse(currentData?.description || '{}').spec || {};
return (
<SelectedCard>
<Flex align="flex-start" orientation="vertical">
<span
style={{
color: 'var(--ant-color-text)',
fontWeight: 400
}}
>
{description.manufacturer
? `${description.product} x ${currentData?.spec?.resources?.accelerator}`
: ''}
</span>
</Flex>
</SelectedCard>
);
};
return (
<div data-field="instanceType">
@@ -115,17 +120,22 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
}
]}
>
<InstanceTypePicker dataList={dataList} value={typeName} />
{action === PageAction.CREATE && (
<InstanceTypePicker selectedInstanceType={selectedInstanceType} />
)}
{action === PageAction.EDIT && renderInstanceType()}
</Form.Item>
</FieldBlock>
{selectedInstanceType?.spec?.acceleratable && (
<Form.Item
{showGPUCount && (
<Form.Item<FormData>
name={['spec', 'resources', 'accelerator']}
hidden={action === PageAction.EDIT}
rules={[
{
required: true,
validator: (_, value) => {
if (value > maxGpuCount) {
const num = Number(value);
if (num > maxGpuCount) {
return Promise.reject(
new Error(
intl.formatMessage(
@@ -135,16 +145,12 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
)
);
}
if (value < 1) {
if (num < 1) {
return Promise.reject(
new Error(
intl.formatMessage(
{
id: 'gpuservice.instance.gpuCount.min'
},
{
count: 1
}
{ id: 'gpuservice.instance.gpuCount.min' },
{ count: 1 }
)
)
);
@@ -156,12 +162,14 @@ const InstanceTypeFormItem: React.FC<InstanceTypeFormItemProps> = ({
>
<NumberSelection
min={1}
onChange={handleOnGPUCountChange}
max={maxGpuCount}
step={1}
required
disabled={disabled || action === PageAction.EDIT}
labelExtra={
!maxGpuCount && (
!maxGpuCount &&
action !== PageAction.EDIT && (
<Alert
showIcon
type="warning"
@@ -8,14 +8,12 @@ import GPUServiceStorageForm from '../../storage/forms';
interface StorageOverlayProps {
open: boolean;
namespace?: string;
onCancel: () => void;
onSubmit: (values: StorageFormData) => Promise<void> | void;
}
const StorageOverlay: React.FC<StorageOverlayProps> = ({
open,
namespace,
onCancel,
onSubmit
}) => {
@@ -66,7 +64,6 @@ const StorageOverlay: React.FC<StorageOverlayProps> = ({
ref={formRef}
action={PageAction.CREATE}
open={open}
namespace={namespace}
onFinish={handleFinish}
/>
</FormOverlayView>
@@ -1,31 +1,36 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import {
Input as CInput,
InputNumber as CInputNumber,
CheckboxField,
LabelInfo,
Select,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Flex, Form, Radio } from 'antd';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { FormData as StorageFormData } from '../../storage/config/types';
import useCreateStorage from '../../storage/services/use-create-storage';
import useQueryStorage from '../../storage/services/use-query-storage';
import { StorageModeValueMap } from '../config';
import useQueryStorageTypes from '../../storage-types/services/use-query-storage-types';
import { DEFAULT_PV_CAPACITY_GB, StorageModeValueMap } from '../config';
import { FormData } from '../config/types';
import StorageOverlay from './storage-overlay';
const FieldBlock = styled.div`
margin-bottom: 24px;
`;
const DEFAULT_TEMP_CAPACITY_GB = 50;
const detectMode = (volume?: FormData['spec']['volume']) => {
if (volume?.persistentTemplate) {
return { mode: StorageModeValueMap.Persistent, releaseWithInstance: true };
}
if (volume?.persistent) {
return { mode: StorageModeValueMap.Persistent, releaseWithInstance: false };
}
return { mode: StorageModeValueMap.Temporary, releaseWithInstance: false };
};
const StorageVolume = ({
disabled,
action
@@ -35,50 +40,86 @@ const StorageVolume = ({
}) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
const [overlayOpen, setOverlayOpen] = useState(false);
const form = Form.useFormInstance<FormData>();
const currentVolume = Form.useWatch(['spec', 'volume'], form);
const [storageMode, setStorageMode] = useState<string>(
StorageModeValueMap.Temporary
);
const form = Form.useFormInstance<FormData>();
const currentCluster = useAtomValue(currentClusterAtom);
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const [releaseWithInstance, setReleaseWithInstance] = useState<boolean>(false);
useEffect(() => {
fetchStorage({});
if (!currentVolume) return;
const detected = detectMode(currentVolume);
setStorageMode(detected.mode);
setReleaseWithInstance(detected.releaseWithInstance);
}, [currentVolume]);
const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } =
useQueryStorage();
const { detailData: storageTypesData, fetchData: fetchStorageTypes } =
useQueryStorageTypes();
const [overlayOpen, setOverlayOpen] = useState(false);
useEffect(() => {
fetchStorage({ page: 1, perPage: 100 });
fetchStorageTypes({ page: 1, perPage: 100 });
}, []);
const storageOptions = useMemo(
() =>
(storageData?.items || []).map((item) => ({
label: `${item.metadata?.name} / ${item.spec?.capacity ?? '-'}`,
value: item.metadata?.name
label: `${item.name} / ${item.spec?.capacity ?? '-'}`,
value: item.name
})),
[storageData]
);
const handleModeChange = (mode: string) => {
console.log('selected storage mode', mode);
if (mode === StorageModeValueMap.Existing) {
form.setFieldValue(['spec', 'volume'], { persistent: { name: '' } });
} else {
const storageTypeOptions = useMemo(
() =>
(storageTypesData?.items || []).map((item) => ({
label: item.displayName || item.name,
value: item.name
})),
[storageTypesData]
);
const applyMode = (mode: string, release: boolean) => {
if (mode === StorageModeValueMap.Temporary) {
form.setFieldValue(['spec', 'volume'], {
ephemeral: { capacity: `${DEFAULT_TEMP_CAPACITY_GB}Gi` }
});
return;
}
if (release) {
form.setFieldValue(['spec', 'volume'], {
persistentTemplate: {
spec: { type: '', capacity: `${DEFAULT_PV_CAPACITY_GB}Gi` },
releaseWithInstance: true
}
});
return;
}
form.setFieldValue(['spec', 'volume'], { persistent: { name: '' } });
};
const handleModeChange = (mode: string) => {
setStorageMode(mode);
applyMode(mode, releaseWithInstance);
};
const handleReleaseChange = (e: any) => {
const next = !!e?.target?.checked;
setReleaseWithInstance(next);
applyMode(storageMode, next);
};
const handleCreateStorage = async (values: StorageFormData) => {
try {
await createStorage({ data: values });
await fetchStorage({});
await fetchStorage({ page: 1, perPage: 100 });
form.setFieldValue(['spec', 'volume'], {
persistent: { name: values.metadata.name }
persistent: { name: values.name }
});
setOverlayOpen(false);
} catch (error) {
@@ -89,90 +130,48 @@ const StorageVolume = ({
return (
<>
<div data-field="storage"></div>
<Flex
style={{
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6
}}
>
<Radio.Group
disabled={disabled}
style={{ marginBottom: 12 }}
value={storageMode}
onChange={(e) => handleModeChange(e.target.value)}
options={[
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.temporary.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.temporary'
})}
</span>
}
/>
),
value: StorageModeValueMap.Temporary
},
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
</span>
}
/>
),
value: StorageModeValueMap.Existing
}
]}
/>
{action === PageAction.CREATE &&
storageMode === StorageModeValueMap.Existing && (
<Button
type="link"
size="small"
style={{ marginBottom: 6 }}
onClick={() => setOverlayOpen(true)}
>
{intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button>
)}
</Flex>
{storageMode === StorageModeValueMap.Existing && (
<Form.Item
name={['spec', 'volume', 'persistent', 'name']}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.required'
})
}
]}
>
<Select
disabled={disabled}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
required
options={storageOptions}
/>
</Form.Item>
)}
<Radio.Group
disabled={disabled}
style={{ marginBottom: 12 }}
value={storageMode}
onChange={(e) => handleModeChange(e.target.value)}
options={[
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.temporary.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.temporary'
})}
</span>
}
/>
),
value: StorageModeValueMap.Temporary
},
{
label: (
<LabelInfo
description={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.tips'
})}
label={
<span className="text-primary">
{intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
</span>
}
/>
),
value: StorageModeValueMap.Persistent
}
]}
/>
{storageMode === StorageModeValueMap.Temporary && (
<Form.Item
@@ -203,6 +202,112 @@ const StorageVolume = ({
/>
</Form.Item>
)}
{storageMode === StorageModeValueMap.Persistent && (
<>
<Flex
style={{
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12
}}
>
<CheckboxField
disabled={disabled}
checked={releaseWithInstance}
onChange={handleReleaseChange}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.releaseWithInstance'
})}
/>
{action === PageAction.CREATE && !releaseWithInstance && (
<Button
type="link"
size="small"
onClick={() => setOverlayOpen(true)}
>
{intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button>
)}
</Flex>
{releaseWithInstance ? (
<>
<Form.Item
name={['spec', 'volume', 'persistentTemplate', 'spec', 'type']}
rules={[
{
required: true,
message: getRuleMessage('select', 'gpuservice.storageType')
}
]}
>
<Select
required
disabled={disabled}
label={intl.formatMessage({ id: 'gpuservice.storageType' })}
options={storageTypeOptions}
/>
</Form.Item>
<Form.Item
name={[
'spec',
'volume',
'persistentTemplate',
'spec',
'capacity'
]}
getValueProps={(val) => ({
value: val
? Number(String(val).replace(/Gi$/i, '')) || undefined
: undefined
})}
normalize={(val) => (val ? `${val}Gi` : '')}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.capacity.required'
})
}
]}
>
<CInputNumber
required
disabled={disabled}
min={1}
precision={0}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.capacity'
})}
/>
</Form.Item>
</>
) : (
<Form.Item
name={['spec', 'volume', 'persistent', 'name']}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.required'
})
}
]}
>
<Select
disabled={disabled}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume'
})}
required
options={storageOptions}
/>
</Form.Item>
)}
</>
)}
<Form.Item<FormData>
name={['spec', 'volumeMount']}
rules={[
@@ -226,7 +331,6 @@ const StorageVolume = ({
<StorageOverlay
open={overlayOpen}
namespace={namespace}
onCancel={() => setOverlayOpen(false)}
onSubmit={handleCreateStorage}
/>
@@ -34,7 +34,7 @@ type ConnectEntry =
const getConnectEntries = (record: ListItem): ConnectEntry[] => {
const ip = record.status?.hostIPs?.[0]?.ip;
const ports = record.status?.ports || [];
const hasSshKey = !!record.spec?.sshPublicKey?.name;
const hasSshKey = !!record.spec?.sshPublicKeys?.length;
const configPorts = record.spec?.ports || [];
if (!ip) {
@@ -66,6 +66,27 @@ const getConnectEntries = (record: ListItem): ConnectEntry[] => {
});
};
const renderInstanceType = (record: ListItem) => {
const description = JSON.parse(record?.description || '{}').spec || {};
return (
<Flex align="flex-start" orientation="vertical">
<span
style={{
color: 'var(--ant-color-text)',
fontWeight: 400
}}
>
{description.manufacturer
? `${description.product} x ${record.spec?.resources?.accelerator}`
: ''}
</span>
<span className="text-tertiary">
{`${record.spec.resources?.cpu}C / RAM: ${record.spec.resources?.ram?.replace('Gi', 'GB')} / Disk: ${record.spec.resources?.localStorage?.replace('Gi', 'GB')}`}{' '}
</span>
</Flex>
);
};
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
@@ -80,15 +101,15 @@ const useInstancesColumns = ({
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: ['metadata', 'name'],
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{text}</span>
<span className="text-primary">{text || record.displayName}</span>
</AutoTooltip>
)
},
@@ -198,40 +219,19 @@ const useInstancesColumns = ({
ellipsis: {
showTitle: false
},
width: 230,
render: (text: string) => (
render: (_text: string, record: ListItem) => (
<AutoTooltip
ghost
style={{ maxWidth: 360, color: 'var(--ant-color-text) !important' }}
>
<Flex align="center">
<span
style={{
fontWeight: 400
}}
>
NVIDIA A100 (x2)
</span>
<span
className="text-secondary"
style={{
display: 'flex',
width: 4,
height: 4,
borderRadius: '50%',
backgroundColor: 'var(--ant-color-text-secondary)',
margin: '0 8px'
}}
></span>
<span className="text-tertiary">4C / 16GB</span>
</Flex>
{renderInstanceType(record)}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: ['metadata', 'creationTimestamp'],
key: 'creationTimestamp',
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: {
showTitle: false
@@ -1,14 +1,9 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { ListItem } from '../config/types';
// View events still hits the K8s proxy. clusterID and namespace are taken
// from the row itself (`row.clusterId`, `row.status.namespace`).
const useViewEvents = () => {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
name: string;
@@ -22,13 +17,11 @@ const useViewEvents = () => {
});
const openModal = (row?: ListItem) => {
const name = row?.metadata?.name || '';
const rowNamespace = row?.metadata?.namespace || namespace;
setOpenModalStatus({
open: true,
name,
namespace: rowNamespace,
clusterID
name: row?.name || '',
namespace: row?.status?.namespace || '',
clusterID: row?.clusterId ?? undefined
});
};
@@ -1,15 +1,11 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { GPU_SERVICE_INSTANCES_LOG_API } from '../apis';
import { ListItem } from '../config/types';
// View logs still hits the K8s proxy. clusterID and namespace are taken
// from the row itself (`row.clusterId`, `row.status.namespace`) since the
// page no longer has a global cluster selector.
const useViewLogs = () => {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
url: string;
@@ -23,17 +19,14 @@ const useViewLogs = () => {
});
const openModal = (row?: ListItem) => {
const name = row?.metadata?.name;
const rowNamespace = row?.metadata?.namespace || namespace;
const name = row?.name;
const namespace = row?.status?.namespace;
const clusterID = row?.clusterId ?? undefined;
setOpenModalStatus({
open: true,
url:
name && clusterID
? `${GPU_SERVICE_INSTANCES_LOG_API({
namespace: rowNamespace,
clusterID,
name
})}`
name && namespace && clusterID
? GPU_SERVICE_INSTANCES_LOG_API({ namespace, clusterID, name })
: '',
tail: 1000,
status: row?.status?.phase || undefined
+72 -345
View File
@@ -1,14 +1,8 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import { handleBatchRequest } from '@/utils';
import { PlusOutlined } from '@ant-design/icons';
import {
BaseSelect,
DeleteModal,
DropdownButtons,
FilterBar,
@@ -17,26 +11,13 @@ import {
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import {
Button,
ConfigProvider,
Divider,
Flex,
message,
Modal,
Space,
Table
} from 'antd';
import { useAtom } from 'jotai';
import { Button, ConfigProvider, message, Modal, Space, Table } from 'antd';
import _ from 'lodash';
import { useCallback, useEffect, useMemo } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceInstance,
GPU_SERVICE_INSTANCES_API,
queryGPUServiceInstances,
startGPUServiceInstance,
stopGPUServiceInstance
queryGPUServiceInstances
} from './apis';
import AddModal from './components/add-modal';
import ViewEventsModal from './components/view-events-modal';
@@ -52,72 +33,7 @@ import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => {
const intl = useIntl();
const [modal, modalContextHolder] = Modal.useModal();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
// In admin "All" view there's no Org context, so the helper falls
// back to the selected cluster's owner Org name.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const deleteInstance = useCallback(
(id: number) => deleteGPUServiceInstance({ namespace, clusterID, id }),
[namespace, clusterID]
);
const startInstance = useCallback(
(row: ListItem) =>
startGPUServiceInstance({
namespace: row.metadata?.namespace || namespace,
clusterID,
name: row.metadata?.name as string
}),
[namespace, clusterID]
);
const stopInstance = useCallback(
(row: ListItem) =>
stopGPUServiceInstance({
namespace: row.metadata?.namespace || namespace,
clusterID,
name: row.metadata?.name as string
}),
[namespace, clusterID]
);
const fetchInstances = useMemoizedFn(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
const effectiveClusterID = params.cluster_id ?? clusterID;
if (!effectiveClusterID) {
return {
items: [],
pagination: {
total: 0,
totalPage: 0,
page: 1,
perPage: params.perPage || 10
}
} as Global.PageResponse<ListItem>;
}
const res = await queryGPUServiceInstances(
{ ...params, namespace, clusterID: effectiveClusterID },
options
);
const total = res?.items?.length ?? 0;
const perPage = params.perPage || 10;
return {
items: res?.items ?? [],
pagination: {
total,
totalPage: Math.ceil(total / perPage),
page: params.page || 1,
perPage
}
};
}
);
const [, modalContextHolder] = Modal.useModal();
const {
dataSource,
@@ -130,16 +46,14 @@ const GPUService: React.FC = () => {
fetchData,
handlePageChange,
handleTableChange,
handleQueryChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Instances,
fetchAPI: fetchInstances,
deleteAPI: deleteInstance,
watch: false,
polling: true,
API: GPU_SERVICE_INSTANCES_API({ namespace, clusterID }),
fetchAPI: queryGPUServiceInstances,
deleteAPI: deleteGPUServiceInstance,
watch: true,
API: GPU_SERVICE_INSTANCES_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.instance' })
});
@@ -160,55 +74,11 @@ const GPUService: React.FC = () => {
closeViewEventsModal,
openViewEventsModalStatus
} = useViewEvents();
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
clusterList
} = useQueryClusterList();
const k8sClusterList = useMemo(
() =>
clusterList.filter(
(item) => item.provider === ProviderValueMap.Kubernetes
),
[clusterList]
);
useEffect(() => {
fetchClusterList({ page: -1 }).then((clusters) => {
const k8sClusters = clusters.filter(
(item: any) => item.provider === ProviderValueMap.Kubernetes
);
if (k8sClusters.length === 0) {
return;
}
const storedCluster = k8sClusters.find(
(item: any) => item.id === currentCluster?.id
);
const targetCluster = storedCluster ?? k8sClusters[0];
if (!storedCluster) {
setCurrentCluster({
...targetCluster,
label: targetCluster.name,
value: targetCluster.id
});
}
handleQueryChange({
cluster_id: targetCluster.id,
page: 1
});
});
return () => {
cancelClusterRequest();
};
}, []);
const handleModalOk = async (data: FormData) => {
try {
if (openInstanceModalStatus.realAction === PageAction.CREATE) {
await deleteInstance(
openInstanceModalStatus.currentData!.metadata?.name as any
);
await deleteGPUServiceInstance(openInstanceModalStatus.currentData!.id);
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
@@ -226,161 +96,34 @@ const GPUService: React.FC = () => {
closeInstanceModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
message.error(intl.formatMessage({ id: 'common.message.fail' }));
// ignore
}
};
const handleRecreate = useMemoizedFn((row: ListItem) => {
modalRef.current?.show({
title: intl.formatMessage({
id: 'gpuservice.instance.recreate.confirm.title'
}),
content: 'gpuservice.instance.recreate.confirm.content',
okText: 'common.button.recreate',
operation: 'gpuservice.instance.recreate.confirm.content',
name: row.metadata?.name,
onOk: async () => {
try {
await deleteInstance(row.metadata?.name as any);
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
await createInstance({
data: {
metadata: { name: row.metadata.name, namespace },
spec: row.spec
} as FormData
});
message.success(intl.formatMessage({ id: 'common.message.success' }));
handleSearch();
} catch (error) {
message.error(intl.formatMessage({ id: 'common.message.fail' }));
}
}
});
});
const handleStart = useMemoizedFn(async (row: ListItem) => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.start.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.start',
operation: 'common.start.single.confirm',
name: row.metadata?.name,
async onOk() {
try {
await startInstance(row);
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchData();
} catch (error) {
// ignore
}
}
});
});
const handleStop = useMemoizedFn(async (row: ListItem) => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.stop.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.stop',
operation: 'common.stop.single.confirm',
name: row.metadata?.name,
async onOk() {
try {
await stopInstance(row);
message.success(intl.formatMessage({ id: 'common.message.success' }));
fetchData();
} catch (error) {
// ignore
}
}
});
});
const handleStartBatch = useMemoizedFn(() => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.start.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.start',
operation: 'common.start.confirm',
selection: true,
async onOk() {
const res = await handleBatchRequest(
rowSelection.selectedRows as ListItem[],
startInstance
);
fetchData();
return res;
}
});
});
const handleStopBatch = useMemoizedFn(() => {
modalRef.current?.show({
title: intl.formatMessage({ id: 'common.title.stop.confirm' }),
content: 'gpuservice.instance',
okText: 'common.button.stop',
operation: 'common.stop.confirm',
selection: true,
async onOk() {
const res = await handleBatchRequest(
rowSelection.selectedRows as ListItem[],
stopInstance
);
fetchData();
return res;
}
});
});
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'view') {
openViewInstanceModal(row);
} else if (val === 'edit') {
openEditInstanceModal(row);
} else if (val === 'delete') {
handleDelete({
...row,
name: row.metadata?.name,
id: row.metadata?.name as any
});
handleDelete({ ...row, name: row.name });
} else if (val === 'recreate') {
// Keep handleRecreate above for the old confirm-modal flow. The current
// UX opens the editable form and recreates after submit.
openRecreateInstanceModal(row);
} else if (val === 'viewlog') {
openViewLogsModal(row);
} else if (val === 'viewevent') {
openViewEventsModal(row);
} else if (val === 'start') {
handleStart(row);
} else if (val === 'stop') {
handleStop(row);
}
// start / stop are disabled until backend endpoints exist
});
const handleBatchActionSelect = useMemoizedFn((val: string) => {
if (val === 'start') {
handleStartBatch();
} else if (val === 'stop') {
handleStopBatch();
} else if (val === 'delete') {
if (val === 'delete') {
handleDeleteBatch();
}
// start / stop are disabled until backend endpoints exist
});
const handleClusterChange = (value: number) => {
const cluster = k8sClusterList.find((item) => item.value === value);
if (cluster) {
setCurrentCluster(cluster);
}
handleQueryChange({
cluster_id: value,
page: 1
});
};
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
@@ -389,7 +132,7 @@ const GPUService: React.FC = () => {
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-cloud-outlined" />}
filters={_.pick(queryParams, ['search', 'manufacturer'])}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.instance.nofound'
})}
@@ -411,80 +154,64 @@ const GPUService: React.FC = () => {
});
return (
<PageContainerInner
leftContent={
<Flex align="center">
<span>{intl.formatMessage({ id: 'menu.gpuService.instances' })}</span>
<Divider
orientation="vertical"
style={{
marginLeft: 16
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
handleSearch={handleSearch}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
right={
<Space size={16}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={openCreateInstanceModal}
>
{intl.formatMessage({ id: 'gpuservice.instance.add' })}
</Button>
<DropdownButtons
items={batchActionList}
extra={
rowSelection.selectedRowKeys.length > 0 && (
<span>({rowSelection.selectedRowKeys.length})</span>
)
}
size="large"
showText={true}
disabled={!rowSelection.selectedRowKeys.length}
onSelect={handleBatchActionSelect}
/>
</Space>
}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
<BaseSelect
variant="borderless"
value={queryParams.cluster_id}
options={k8sClusterList}
onChange={handleClusterChange}
style={{ minWidth: 120, fontWeight: 500 }}
></BaseSelect>
</Flex>
}
>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
selectOptions={k8sClusterList}
select={{ showSearch: true }}
selectHolder={intl.formatMessage({
id: 'gpuservice.instance.filter.cluster'
})}
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
right={
<Space size={16}>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={openCreateInstanceModal}
>
{intl.formatMessage({ id: 'gpuservice.instance.add' })}
</Button>
<DropdownButtons
items={batchActionList}
extra={
rowSelection.selectedRowKeys.length > 0 && (
<span>({rowSelection.selectedRowKeys.length})</span>
)
}
size="large"
showText={true}
disabled={!rowSelection.selectedRowKeys.length}
onSelect={handleBatchActionSelect}
/>
</Space>
}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.metadata.name}
onChange={handleTableChange}
pagination={false}
/>
</ConfigProvider>
</ConfigProvider>
</PageBox>
<AddModal
open={openInstanceModalStatus.open}
action={openInstanceModalStatus.action}
@@ -510,7 +237,7 @@ const GPUService: React.FC = () => {
/>
<DeleteModal ref={modalRef} />
{modalContextHolder}
</PageContainerInner>
</>
);
};
@@ -1,9 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServiceInstance } from '../apis';
import { FormData, ListItem } from '../config/types';
@@ -12,31 +8,10 @@ interface CreateInstanceParams {
}
export default function useCreateInstance() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: CreateInstanceParams, option?: any) =>
createGPUServiceInstance(
{
namespace,
clusterID,
data: {
apiVersion,
kind: KindMapping.instance,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: CreateInstanceParams) =>
createGPUServiceInstance({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,33 +1,20 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstanceEvents } from '../apis';
import { InstanceEvents } from '../config/types';
interface QueryInstanceEventsParams {
name: string;
namespace: string;
clusterID?: number;
pretty?: string;
}
export default function useQueryInstanceEvents() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: QueryInstanceEventsParams, options?: any) =>
queryGPUServiceInstanceEvents(
{
namespace,
clusterID,
name: params.name,
pretty: params.pretty
},
options
),
[namespace, clusterID]
queryGPUServiceInstanceEvents(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,31 +1,19 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstanceLog } from '../apis';
import { InstanceLog, InstanceLogQueryParams } from '../config/types';
interface QueryInstanceLogParams extends InstanceLogQueryParams {
name: string;
namespace: string;
clusterID?: number;
}
export default function useQueryInstanceLog() {
const currentCluster = useAtomValue(currentClusterAtom);
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const clusterID = currentCluster?.id;
const fetchDetail = useCallback(
(params: QueryInstanceLogParams, options?: any) =>
queryGPUServiceInstanceLog(
{
...params,
namespace,
clusterID
},
options
),
[namespace, clusterID]
queryGPUServiceInstanceLog(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,32 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstanceTypes } from '../apis';
import { transformInstanceType } from '../config';
import { InstanceTypeItem } from '../config/types';
export default function useQueryInstanceTypes() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const fetchDetail = useCallback(
async (params: Global.K8sSearchParams = {}, options?: any) => {
const res = await queryGPUServiceInstanceTypes(
{ ...params, clusterID },
options
);
return {
...res,
items: (res?.items || []).map(transformInstanceType)
};
},
[clusterID]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceInstanceTypes(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<InstanceTypeItem>,
Global.K8sSearchParams
Global.PageResponse<InstanceTypeItem>,
Global.SearchParams
>({
fetchDetail,
key: 'instanceTypes'
@@ -1,32 +1,19 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceInstancePVEvents } from '../apis';
import { InstanceEvents } from '../config/types';
interface QueryVolumeEventsParams {
name: string;
pretty?: string;
namespace: string;
clusterID?: number;
}
export default function useQueryVolumeEvents() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: QueryVolumeEventsParams, options?: any) =>
queryGPUServiceInstancePVEvents(
{
namespace,
clusterID,
name: params.name
},
options
),
[namespace, clusterID]
queryGPUServiceInstancePVEvents(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,44 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServiceInstance } from '../apis';
import { FormData, ListItem } from '../config/types';
interface UpdateInstanceParams {
id: number;
data: FormData;
data: Partial<FormData>;
}
export default function useUpdateInstance() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: UpdateInstanceParams, option?: any) =>
updateGPUServiceInstance(
{
namespace,
clusterID,
id: params.id,
data: {
apiVersion,
kind: KindMapping.instance,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: UpdateInstanceParams) =>
updateGPUServiceInstance({ id: params.id, data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,10 +1,13 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../types';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_PUBLIC_KEY_API = '/gpu-instance-ssh-public-keys/data';
export const GPU_SERVICE_PUBLIC_KEY_API = '/gpu-instance-ssh-public-keys';
export async function queryGPUServicePublicKeys(params: {}, options?: any) {
return request<ListItem>(GPU_SERVICE_PUBLIC_KEY_API, {
export async function queryGPUServicePublicKeys(
params: Global.SearchParams,
options?: any
) {
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_PUBLIC_KEY_API, {
method: 'GET',
params,
cancelToken: options?.token
@@ -18,8 +21,11 @@ export async function createGPUServicePublicKey(params: { data: FormData }) {
});
}
export async function updateGPUServicePublicKey(params: { data: FormData }) {
return request<ListItem>(`${GPU_SERVICE_PUBLIC_KEY_API}`, {
export async function updateGPUServicePublicKey(params: {
id: number;
data: Omit<FormData, 'name'>;
}) {
return request<ListItem>(`${GPU_SERVICE_PUBLIC_KEY_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
@@ -0,0 +1,70 @@
import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormData, ListItem } from '../config/types';
import GPUServicePublicKeyForm from '../forms';
type AddPublicKeyModalProps = {
title: string;
action: PageActionType;
open: boolean;
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
};
const AddPublicKeyModal: React.FC<AddPublicKeyModalProps> = ({
title,
action,
open,
onOk,
data,
onCancel
}) => {
const form = useRef<any>(null);
const handleSubmit = () => {
form.current?.submit();
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
onOk({ ...values });
};
return (
<FormDrawer
title={title}
open={open}
onCancel={handleCancel}
onSubmit={handleSubmit}
width={600}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServicePublicKeyForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
</FormDrawer>
);
};
export default AddPublicKeyModal;
@@ -0,0 +1,23 @@
export interface FormData {
name: string;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
spec: {
data: string;
};
}
export interface ListItem {
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
name?: string;
displayName?: string | null;
description?: string | null;
spec: {
data: string;
};
}
@@ -0,0 +1,69 @@
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
const Basic = ({ action }: { action: string }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
},
{
pattern: validateLabelNameRegxFor63,
message: intl.formatMessage({ id: 'gpuservice.form.rule.name' })
}
]}
>
<CInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })}
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'data']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.publicKey.label')
}
]}
>
<Textarea
required
alwaysFocus
label={intl.formatMessage({ id: 'gpuservice.publicKey.label' })}
placeholder={intl.formatMessage({
id: 'gpuservice.publicKey.placeholder'
})}
trim={false}
autoSize={{ minRows: 6, maxRows: 12 }}
/>
</Form.Item>
</>
);
};
export default Basic;
@@ -0,0 +1,62 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import { FormData, ListItem } from '../config/types';
import Basic from './basic';
interface PublicKeyFormProps {
ref?: any;
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
onFinish: (values: FormData) => Promise<void>;
}
const GPUServicePublicKeyForm: React.FC<PublicKeyFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
name: currentData.name as string,
displayName: currentData.displayName,
description: currentData.description,
spec: {
data: currentData.spec?.data ?? ''
}
});
}
}, [action, currentData, form, open]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
return (
<Form
name="gpuServicePublicKeyForm"
form={form}
onFinish={onFinish}
preserve={false}
initialValues={{}}
>
<Basic action={action} />
</Form>
);
}
);
export default GPUServicePublicKeyForm;
@@ -0,0 +1,52 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreatePublicKeyModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
const openModal = (
action: PageActionType,
title: string,
row?: ListItem | null
) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row ?? null
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
restoreScrollHeight();
};
return {
openPublicKeyModalStatus: openModalStatus,
openPublicKeyModal: openModal,
closePublicKeyModal: closeModal
};
};
export default useCreatePublicKeyModal;
@@ -0,0 +1,95 @@
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { ListItem } from '../config/types';
const rowActionList = [
{
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
props: {
danger: true
}
}
];
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
}
const usePublicKeyColumns = ({
handleSelect,
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{record.displayName || text}</span>
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.description' }),
dataIndex: 'description',
key: 'description',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
<AutoTooltip ghost>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.operation' }),
key: 'operation',
dataIndex: 'operation',
render: (_text, record) => (
<DropdownButtons
items={rowActionList}
onSelect={(val) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, sortOrder, intl]);
};
export default usePublicKeyColumns;
+154 -73
View File
@@ -1,94 +1,175 @@
import { Input as CInput, Textarea, useAppUtils } from '@gpustack/core-ui';
import { PageAction } from '@/config';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Form, message } from 'antd';
import React, { useEffect } from 'react';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import PageBox from '../../_components/page-box';
import useGetSshkey from './services/use-get-sshkey';
import {
deleteGPUServicePublicKey,
GPU_SERVICE_PUBLIC_KEY_API,
queryGPUServicePublicKeys
} from './apis';
import AddPublicKeyModal from './components/add-public-key-modal';
import { FormData, ListItem } from './config/types';
import useCreatePublicKeyModal from './hooks/use-create-public-key-modal';
import usePublicKeyColumns from './hooks/use-public-key-columns';
import useCreateSshkey from './services/use-create-sshkey';
import useUpdateSshkey from './services/use-update-sshkey';
import { FormData } from './types';
const GPUServicePublicKeys: React.FC = () => {
const intl = useIntl();
const [form] = Form.useForm<FormData>();
const { getRuleMessage } = useAppUtils();
const { fetchData: fetchSshkey } = useGetSshkey();
const { fetchData: updateSshkey, loading: updating } = useUpdateSshkey();
useEffect(() => {
fetchSshkey({}).then((res) => {
const data = res?.spec?.data || '';
form.setFieldsValue({
name: res?.name,
spec: {
data
}
});
});
}, []);
const {
dataSource,
rowSelection,
queryParams,
sortOrder,
modalRef,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: 'PublicKeys',
fetchAPI: queryGPUServicePublicKeys,
deleteAPI: deleteGPUServicePublicKey,
watch: false,
polling: false,
API: GPU_SERVICE_PUBLIC_KEY_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.publicKey' })
});
const handleSave = async () => {
form.submit();
const { fetchData: createSshkey } = useCreateSshkey();
const { fetchData: updateSshkey } = useUpdateSshkey();
const { openPublicKeyModalStatus, openPublicKeyModal, closePublicKeyModal } =
useCreatePublicKeyModal();
const handleAdd = () => {
openPublicKeyModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.publicKey.add' })
);
};
const onFinish = async (values: FormData) => {
const handleEdit = (row: ListItem) => {
openPublicKeyModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.publicKey.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
try {
await updateSshkey({
data: values
});
if (openPublicKeyModalStatus.action === PageAction.EDIT) {
await updateSshkey({
id: openPublicKeyModalStatus.currentData!.id,
data: data
});
} else {
await createSshkey({ data });
}
fetchData();
closePublicKeyModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// ignore
// it's handled in interceptor
}
};
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'edit') {
handleEdit(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name as string });
}
});
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-storage-outlined" />}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.sshkey.nofound'
})}
title={intl.formatMessage({ id: 'noresult.gpuservice.sshkey.title' })}
subTitle={intl.formatMessage({
id: 'noresult.gpuservice.sshkey.subTitle'
})}
onClick={handleAdd}
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
/>
);
};
const columns = usePublicKeyColumns({
handleSelect,
sortOrder
});
return (
<PageBox>
<Form
style={{ width: 700 }}
name="gpuServicePublicKeyForm"
form={form}
onFinish={onFinish}
initialValues={{
name: 'SSHPublicKey',
spec: {
data: ''
}
}}
>
<Form.Item<FormData> name="name" hidden>
<CInput.Input />
</Form.Item>
<Form.Item<FormData>
name={['spec', 'data']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.publicKey.label')
}
]}
>
<Textarea
required
label={intl.formatMessage({ id: 'gpuservice.publicKey.label' })}
placeholder={intl.formatMessage({
id: 'gpuservice.publicKey.placeholder'
})}
trim={false}
alwaysFocus
autoSize={{ minRows: 8, maxRows: 16 }}
style={{ width: 700, minHeight: 186 }}
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
inputHolder={intl.formatMessage({
id: 'gpuservice.publicKey.filter.name'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.publicKey.add' })}
handleSearch={handleSearch}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAdd}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</Form.Item>
<Button
type="primary"
loading={updating}
style={{ width: 120, marginTop: 24 }}
onClick={handleSave}
>
{intl.formatMessage({ id: 'common.button.save' })}
</Button>
</Form>
</PageBox>
</ConfigProvider>
</PageBox>
<AddPublicKeyModal
open={openPublicKeyModalStatus.open}
action={openPublicKeyModalStatus.action}
title={openPublicKeyModalStatus.title}
data={openPublicKeyModalStatus.currentData}
onCancel={closePublicKeyModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</>
);
};
@@ -1,7 +1,7 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { createGPUServicePublicKey } from '../apis';
import { FormData, ListItem } from '../types';
import { FormData, ListItem } from '../config/types';
interface CreateSshkeyParams {
data: FormData;
@@ -1,24 +0,0 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../types';
export default function useGetSshkey() {
const fetchDetail = useCallback(
(params: {}, options?: any) => queryGPUServicePublicKeys(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } =
useQueryData<ListItem>({
fetchDetail,
key: 'sshkey'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,27 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryPublicKeys() {
const fetchDetail = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServicePublicKeys(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'publicKeys'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,29 @@
import { useQueryDataList } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServicePublicKeys } from '../apis';
import { ListItem } from '../config/types';
export default function useQuerySshkeys() {
const fetchList = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServicePublicKeys(params, options),
[]
);
const { dataList, loading, cancelRequest, fetchData } = useQueryDataList<
ListItem,
Global.SearchParams
>({
key: 'sshkeyOptions',
fetchList,
getLabel: (item) => item?.name as string,
getValue: (item) => item?.name as string
});
return {
sshkeyOptions: dataList as Array<{ label: string; value: string }>,
loading,
cancelRequest,
fetchData
};
}
@@ -1,16 +1,17 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { updateGPUServicePublicKey } from '../apis';
import { FormData, ListItem } from '../types';
import { FormData, ListItem } from '../config/types';
interface UpdateSshkeyParams {
data: FormData;
id: number;
data: Omit<FormData, 'name'>;
}
export default function useUpdateSshkey() {
const fetchDetail = useCallback(
(params: UpdateSshkeyParams) =>
updateGPUServicePublicKey({ data: params.data }),
updateGPUServicePublicKey({ id: params.id, data: params.data }),
[]
);
@@ -1,13 +0,0 @@
export interface FormData {
name: string;
description: string;
spec: {
data: string;
};
}
export interface ListItem extends FormData {
id: number;
created_at?: string;
updated_at?: string;
}
@@ -0,0 +1,41 @@
import { request } from '@umijs/max';
import { FormData, ListItem } from '../config/types';
export const GPU_SERVICE_STORAGE_TYPE_API =
'/gpu-instance-persistent-volume-types';
export async function queryGPUServiceStorageTypes(
params: Global.SearchParams,
options?: any
) {
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_STORAGE_TYPE_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServiceStorageType(params: {
data: Omit<FormData, 'type'>;
}) {
return request<ListItem>(GPU_SERVICE_STORAGE_TYPE_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServiceStorageType(params: {
id: number;
data: Omit<FormData, 'type' | 'name'>;
}) {
return request<ListItem>(`${GPU_SERVICE_STORAGE_TYPE_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServiceStorageType(id: number) {
return request(`${GPU_SERVICE_STORAGE_TYPE_API}/${id}`, {
method: 'DELETE'
});
}
@@ -0,0 +1,70 @@
import { PageActionType } from '@/config/types';
import { ModalFooter } from '@gpustack/core-ui';
import { useRef } from 'react';
import FormDrawer from '../../../_components/form-drawer';
import { FormData, ListItem } from '../config/types';
import GPUServiceStorageTypeForm from '../forms';
type AddStorageTypeModalProps = {
title: string;
action: PageActionType;
open: boolean;
onOk: (values: FormData) => void;
data?: ListItem | null;
onCancel: () => void;
};
const AddStorageTypeModal: React.FC<AddStorageTypeModalProps> = ({
title,
action,
open,
onOk,
data,
onCancel
}) => {
const form = useRef<any>(null);
const handleSubmit = () => {
form.current?.submit();
};
const handleCancel = () => {
form.current?.resetFields();
onCancel();
};
const onFinish = async (values: FormData) => {
onOk({ ...values });
};
return (
<FormDrawer
title={title}
open={open}
onCancel={handleCancel}
onSubmit={handleSubmit}
width={640}
footer={
<ModalFooter
onOk={handleSubmit}
onCancel={handleCancel}
style={{
padding: '16px 24px 8px',
display: 'flex',
justifyContent: 'flex-end'
}}
/>
}
>
<GPUServiceStorageTypeForm
ref={form}
action={action}
currentData={data}
onFinish={onFinish}
open={open}
/>
</FormDrawer>
);
};
export default AddStorageTypeModal;
@@ -0,0 +1,54 @@
import { icons } from '@gpustack/core-ui';
import { StorageTypeKind } from './types';
export const StorageTypeKindValueMap: Record<string, StorageTypeKind> = {
NFS: 'nfs',
S3: 's3'
};
export const StorageTypeKindLabelMap: Record<string, string> = {
[StorageTypeKindValueMap.NFS]: 'NFS',
[StorageTypeKindValueMap.S3]: 'S3'
};
export const StorageTypeKindOptions = [
{ label: 'NFS', value: StorageTypeKindValueMap.NFS },
{ label: 'S3', value: StorageTypeKindValueMap.S3 }
];
export const DefaultNFSMountOptions = [
'hard',
'vers=4',
'rsize=1048576',
'wsize=1048576',
'noatime',
'nodiratime'
];
export const DefaultS3MountOptions = [
'--no-checksum',
'--memory-limit=4000',
'--max-flushers=32',
'--max-parallel-parts=32',
'--part-sizes=25',
'--list-type=2',
'--no-specials'
];
export const rowActionList = [
{
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.delete',
key: 'delete',
locale: true,
icon: icons.DeleteOutlined,
props: {
danger: true
}
}
];
@@ -0,0 +1,46 @@
export type StorageTypeKind = 'nfs' | 's3';
export interface StorageTypeNFS {
server: string;
share: string;
subDirectory?: string | null;
mountPermissions?: string | null;
mountOptions?: string[] | null;
}
export interface StorageTypeS3 {
endpoint: string;
region?: string | null;
insecure?: boolean | null;
accessKey?: string | null;
secretKey?: string | null;
bucket?: string | null;
mountOptions?: string[] | null;
}
export interface FormData {
name: string;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
type: StorageTypeKind;
spec: {
nfs?: StorageTypeNFS | null;
s3?: StorageTypeS3 | null;
};
}
export interface ListItem {
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
spec: {
nfs?: StorageTypeNFS | null;
s3?: Omit<StorageTypeS3, 'secretKey'> | null;
};
}
@@ -0,0 +1,73 @@
import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import {
Input as CInput,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { StorageTypeKindOptions } from '../config';
import { FormData } from '../config/types';
const Basic = ({ action }: { action: string }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name="name"
rules={[
{
required: true,
message: getRuleMessage('input', 'common.table.name')
},
{
pattern: validateLabelNameRegxFor63,
message: intl.formatMessage({ id: 'gpuservice.form.rule.name' })
}
]}
>
<CInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })}
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
scaleSize={true}
trim={false}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Form.Item<FormData>
name="type"
rules={[
{
required: true,
message: getRuleMessage('select', 'gpuservice.storageType.kind')
}
]}
>
<SealSelect
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storageType.kind' })}
required
options={StorageTypeKindOptions}
/>
</Form.Item>
</>
);
};
export default Basic;
@@ -0,0 +1,129 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import { Form } from 'antd';
import { forwardRef, useEffect, useImperativeHandle } from 'react';
import {
DefaultNFSMountOptions,
DefaultS3MountOptions,
StorageTypeKindValueMap
} from '../config';
import { FormData, ListItem, StorageTypeKind } from '../config/types';
import Basic from './basic';
import NFSForm from './nfs';
import S3Form from './s3';
interface StorageTypeFormProps {
ref?: any;
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
onFinish: (values: FormData) => Promise<void>;
}
const detectKind = (item?: ListItem | null): StorageTypeKind => {
if (item?.spec?.s3) return StorageTypeKindValueMap.S3;
return StorageTypeKindValueMap.NFS;
};
const GPUServiceStorageTypeForm: React.FC<StorageTypeFormProps> = forwardRef(
(props, ref) => {
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
const kind = Form.useWatch('type', form);
useEffect(() => {
if (!open) {
form.resetFields();
return;
}
if (action === PageAction.EDIT && currentData) {
const detected = detectKind(currentData);
form.setFieldsValue({
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
type: detected,
spec: {
nfs: currentData.spec?.nfs ?? null,
s3: (currentData.spec?.s3 ?? null) as any
}
});
return;
}
form.setFieldsValue({
type: StorageTypeKindValueMap.NFS,
spec: {
nfs: {
server: '',
share: '',
mountOptions: DefaultNFSMountOptions
},
s3: null
}
});
}, [action, currentData, form, open]);
useEffect(() => {
if (!open || action === PageAction.EDIT) return;
if (kind === StorageTypeKindValueMap.S3) {
const existing = form.getFieldValue(['spec', 's3']);
if (!existing) {
form.setFieldValue(['spec', 's3'], {
endpoint: '',
insecure: false,
mountOptions: DefaultS3MountOptions
});
}
form.setFieldValue(['spec', 'nfs'], null);
} else if (kind === StorageTypeKindValueMap.NFS) {
const existing = form.getFieldValue(['spec', 'nfs']);
if (!existing) {
form.setFieldValue(['spec', 'nfs'], {
server: '',
share: '',
mountOptions: DefaultNFSMountOptions
});
}
form.setFieldValue(['spec', 's3'], null);
}
}, [kind, open, action, form]);
useImperativeHandle(ref, () => ({
submit: () => {
form.submit();
},
resetFields: () => {
form.resetFields();
}
}));
const handleFinish = async (values: FormData) => {
const isNFS = values.type === StorageTypeKindValueMap.NFS;
await onFinish({
...values,
spec: {
nfs: isNFS ? (values.spec?.nfs ?? null) : null,
s3: !isNFS ? (values.spec?.s3 ?? null) : null
}
});
};
return (
<Form
name="gpuServiceStorageTypeForm"
form={form}
onFinish={handleFinish}
preserve={false}
initialValues={{}}
>
<Basic action={action} />
{kind === StorageTypeKindValueMap.NFS && <NFSForm action={action} />}
{kind === StorageTypeKindValueMap.S3 && <S3Form action={action} />}
</Form>
);
}
);
export default GPUServiceStorageTypeForm;
@@ -0,0 +1,80 @@
import { PageAction } from '@/config';
import { Input as CInput, ListInput, useAppUtils } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Form } from 'antd';
import { FormData } from '../config/types';
const NFSForm = ({ action }: { action: string }) => {
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<Form.Item<FormData>
name={['spec', 'nfs', 'server']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.storageType.nfs.server'
)
}
]}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.server'
})}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 'nfs', 'share']}
rules={[
{
required: true,
message: getRuleMessage('input', 'gpuservice.storageType.nfs.share')
}
]}
>
<CInput.Input
required
label={intl.formatMessage({ id: 'gpuservice.storageType.nfs.share' })}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'subDirectory']}>
<CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.subDirectory'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'mountPermissions']}>
<CInput.Input
placeholder="0755,0777,..."
description={intl.formatMessage({
id: 'gpuservice.storageType.nfs.mountPermissions.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.nfs.mountPermissions'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 'nfs', 'mountOptions']}>
<ListInput
disabled={action === PageAction.EDIT}
label={intl.formatMessage({
id: 'gpuservice.storageType.mountOptions'
})}
btnText={intl.formatMessage({ id: 'common.button.addParams' })}
/>
</Form.Item>
</>
);
};
export default NFSForm;
@@ -0,0 +1,109 @@
import { PageAction } from '@/config';
import {
CheckboxField,
Input as CInput,
ListInput,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { FormData } from '../config/types';
const S3Form = ({ action }: { action: string }) => {
console.log('action', action);
const intl = useIntl();
const { getRuleMessage } = useAppUtils();
return (
<>
<Form.Item<FormData>
name={['spec', 's3', 'endpoint']}
rules={[
{
required: true,
message: getRuleMessage(
'input',
'gpuservice.storageType.s3.endpoint'
)
},
{
pattern: /^https?:\/\//,
message: intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint.rule'
})
}
]}
>
<CInput.Input
required
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.endpoint'
})}
placeholder="http | https://..."
/>
</Form.Item>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 's3', 'region']}>
<CInput.Input
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.region'
})}
/>
</Form.Item>
</div>
<div style={{ flex: 1 }}>
<Form.Item<FormData> name={['spec', 's3', 'bucket']}>
<CInput.Input
description={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.bucket'
})}
/>
</Form.Item>
</div>
</Flex>
<Form.Item<FormData> name={['spec', 's3', 'accessKey']}>
<CInput.Input
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.accessKey'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 's3', 'secretKey']}>
<CInput.Password
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.secretKey'
})}
/>
</Form.Item>
<Form.Item<FormData>
name={['spec', 's3', 'insecure']}
valuePropName="checked"
style={{ marginBottom: 12 }}
>
<CheckboxField
description={intl.formatMessage({
id: 'gpuservice.storageType.s3.insecure.tips'
})}
label={intl.formatMessage({
id: 'gpuservice.storageType.s3.insecure'
})}
/>
</Form.Item>
<Form.Item<FormData> name={['spec', 's3', 'mountOptions']}>
<ListInput
disabled={action === PageAction.EDIT}
label={intl.formatMessage({
id: 'gpuservice.storageType.mountOptions'
})}
btnText={intl.formatMessage({ id: 'common.button.addParams' })}
/>
</Form.Item>
</>
);
};
export default S3Form;
@@ -0,0 +1,52 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreateStorageTypeModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
const openModal = (
action: PageActionType,
title: string,
row?: ListItem | null
) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row ?? null
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
restoreScrollHeight();
};
return {
openStorageTypeModalStatus: openModalStatus,
openStorageTypeModal: openModal,
closeStorageTypeModal: closeModal
};
};
export default useCreateStorageTypeModal;
@@ -0,0 +1,108 @@
import { FolderOutlined } from '@ant-design/icons';
import {
AutoTooltip,
DropdownButtons,
IconFont,
ThemeTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { rowActionList, StorageTypeKindLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
handleSelect: (val: string, record: ListItem) => void;
sortOrder: string[];
}
const getKindLabel = (record: ListItem) => {
if (record.spec?.nfs)
return (
<ThemeTag
style={{ width: 'fit-content' }}
color="cyan"
icon={<FolderOutlined />}
>
{StorageTypeKindLabelMap.nfs}
</ThemeTag>
);
if (record.spec?.s3)
return (
<ThemeTag
style={{ width: 'fit-content' }}
color="green"
icon={<IconFont type="icon-database" />}
>
{StorageTypeKindLabelMap.s3}
</ThemeTag>
);
return '-';
};
const useStorageTypeColumns = ({
handleSelect,
sortOrder
}: ColumnsHookProps): ColumnsType<ListItem> => {
const intl = useIntl();
return useMemo(() => {
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string, record: ListItem) => (
<AutoTooltip ghost minWidth={20}>
<span className="text-primary">{record.displayName || text}</span>
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.type' }),
key: 'kind',
sorter: false,
render: (_text, record) => getKindLabel(record)
},
{
title: intl.formatMessage({ id: 'common.table.description' }),
dataIndex: 'description',
key: 'description',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost minWidth={20}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost>
{text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'common.table.operation' }),
key: 'operation',
dataIndex: 'operation',
render: (_text, record) => (
<DropdownButtons
items={rowActionList}
onSelect={(val) => handleSelect(val, record)}
/>
)
}
];
}, [handleSelect, sortOrder, intl]);
};
export default useStorageTypeColumns;
@@ -0,0 +1,181 @@
import { PageAction } from '@/config';
import { TABLE_SORT_DIRECTIONS } from '@/config/settings';
import useTableFetch from '@/hooks/use-table-fetch';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceStorageType,
GPU_SERVICE_STORAGE_TYPE_API,
queryGPUServiceStorageTypes
} from './apis';
import AddStorageTypeModal from './components/add-storage-type-modal';
import { FormData, ListItem } from './config/types';
import useCreateStorageTypeModal from './hooks/use-create-storage-type-modal';
import useStorageTypeColumns from './hooks/use-storage-type-columns';
import useCreateStorageType from './services/use-create-storage-type';
import useUpdateStorageType from './services/use-update-storage-type';
const GPUServiceStorageTypes: React.FC = () => {
const intl = useIntl();
const {
dataSource,
rowSelection,
queryParams,
sortOrder,
modalRef,
handleDelete,
handleDeleteBatch,
fetchData,
handlePageChange,
handleTableChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: 'StorageTypes',
fetchAPI: queryGPUServiceStorageTypes,
deleteAPI: deleteGPUServiceStorageType,
watch: false,
polling: false,
API: GPU_SERVICE_STORAGE_TYPE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storageType' })
});
const { fetchData: createStorageType } = useCreateStorageType();
const { fetchData: updateStorageType } = useUpdateStorageType();
const {
openStorageTypeModalStatus,
openStorageTypeModal,
closeStorageTypeModal
} = useCreateStorageTypeModal();
const handleAdd = () => {
openStorageTypeModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.storageType.add' })
);
};
const handleEdit = (row: ListItem) => {
openStorageTypeModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.storageType.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
try {
if (openStorageTypeModalStatus.action === PageAction.EDIT) {
await updateStorageType({
id: openStorageTypeModalStatus.currentData!.id,
data: data
});
} else {
await createStorageType({ data: data });
}
fetchData();
closeStorageTypeModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
// it's handled in interceptor
}
};
const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'edit') {
handleEdit(row);
} else if (val === 'delete') {
handleDelete({ ...row, name: row.name });
}
});
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
<NoResult
loading={dataSource.loading}
loadend={dataSource.loadend}
dataSource={dataSource.dataList}
image={<IconFont type="icon-storage-outlined" />}
filters={_.pick(queryParams, ['search'])}
noFoundText={intl.formatMessage({
id: 'noresult.gpuservice.storageType.nofound'
})}
title={intl.formatMessage({
id: 'noresult.gpuservice.storageType.title'
})}
subTitle={intl.formatMessage({
id: 'noresult.gpuservice.storageType.subTitle'
})}
onClick={handleAdd}
buttonText={intl.formatMessage({ id: 'noresult.button.add' })}
/>
);
};
const columns = useStorageTypeColumns({
handleSelect,
sortOrder
});
return (
<>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
inputHolder={intl.formatMessage({
id: 'gpuservice.storageType.filter.name'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.storageType.add' })}
handleSearch={handleSearch}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAdd}
handleInputChange={handleNameChange}
rowSelection={rowSelection}
widths={{ input: 300 }}
/>
<ConfigProvider renderEmpty={renderEmpty}>
<Table
columns={columns}
dataSource={dataSource.dataList}
rowSelection={rowSelection}
loading={{
spinning: dataSource.loading,
size: 'middle'
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
</PageBox>
<AddStorageTypeModal
open={openStorageTypeModalStatus.open}
action={openStorageTypeModalStatus.action}
title={openStorageTypeModalStatus.title}
data={openStorageTypeModalStatus.currentData}
onCancel={closeStorageTypeModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
</>
);
};
export default GPUServiceStorageTypes;
@@ -0,0 +1,31 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { createGPUServiceStorageType } from '../apis';
import { FormData, ListItem } from '../config/types';
interface CreateStorageTypeParams {
data: Omit<FormData, 'type'>;
}
export default function useCreateStorageType() {
const fetchDetail = useCallback(
(params: CreateStorageTypeParams) =>
createGPUServiceStorageType({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
CreateStorageTypeParams
>({
fetchDetail,
key: 'createStorageType'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,27 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { queryGPUServiceStorageTypes } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorageTypes() {
const fetchDetail = useCallback(
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceStorageTypes(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'storageTypes'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
@@ -0,0 +1,32 @@
import { useQueryData } from '@gpustack/core-ui';
import { useCallback } from 'react';
import { updateGPUServiceStorageType } from '../apis';
import { FormData, ListItem } from '../config/types';
interface UpdateStorageTypeParams {
id: number;
data: Omit<FormData, 'type' | 'name'>;
}
export default function useUpdateStorageType() {
const fetchDetail = useCallback(
(params: UpdateStorageTypeParams) =>
updateGPUServiceStorageType({ id: params.id, data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
ListItem,
UpdateStorageTypeParams
>({
fetchDetail,
key: 'updateStorageType'
});
return {
detailData,
loading,
cancelRequest,
fetchData
};
}
+37 -106
View File
@@ -1,125 +1,56 @@
import { request } from '@umijs/max';
import { omitPathParams } from '../../utils';
import { ListItem, StorageClassItem } from '../config/types';
import {
FormData,
ListItem,
StorageClassItem,
UpdateData
} from '../config/types';
export const GPU_SERVICE_STORAGE_API = (params: {
namespace: string;
clusterID?: number;
}) =>
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/namespaces/${params.namespace}/instancepersistentvolumes`;
export const GPU_SERVICE_STORAGE_API = '/gpu-instance-persistent-volumes';
export const STORAGE_CLASS_API = (params: { clusterID?: number }) =>
`/clusters/${params.clusterID}/proxy/apis/storage.k8s.io/v1/storageclasses`;
export const STORAGE_CLASS_API = '/gpu-instance-persistent-volume-types';
export async function queryGPUServiceStorage(
params: Global.K8sSearchParams & {
namespace: string;
clusterID?: number;
},
params: Global.SearchParams,
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<ListItem>>(
GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token
}
);
return request<Global.PageResponse<ListItem>>(GPU_SERVICE_STORAGE_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
export async function createGPUServiceStorage(
params: {
namespace: string;
clusterID?: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
}),
{
method: 'POST',
data: params.data,
cancelToken: option?.token
}
);
export async function createGPUServiceStorage(params: { data: FormData }) {
return request<ListItem>(GPU_SERVICE_STORAGE_API, {
method: 'POST',
data: params.data
});
}
export async function updateGPUServiceStorage(
params: {
namespace: string;
clusterID?: number;
id: number;
data: Global.K8sCommonData;
},
option?: any
) {
if (!params.clusterID) {
return;
}
return request<ListItem>(
`${GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.id}`,
{
method: 'PUT',
data: params.data,
cancelToken: option?.token
}
);
}
export async function deleteGPUServiceStorage(params: {
namespace: string;
clusterID?: number;
export async function updateGPUServiceStorage(params: {
id: number;
data: UpdateData;
}) {
if (!params.clusterID) {
return;
}
return request(
`${GPU_SERVICE_STORAGE_API({
namespace: params.namespace,
clusterID: params.clusterID
})}/${params.id}`,
{
method: 'DELETE'
}
);
return request<ListItem>(`${GPU_SERVICE_STORAGE_API}/${params.id}`, {
method: 'PUT',
data: params.data
});
}
export async function deleteGPUServiceStorage(id: number) {
return request(`${GPU_SERVICE_STORAGE_API}/${id}`, {
method: 'DELETE'
});
}
export async function queryStorageClass(
params: Global.K8sSearchParams & {
clusterID?: number;
},
params: Global.SearchParams,
options?: any
) {
if (!params.clusterID) {
return;
}
return request<Global.K8sPageResponse<StorageClassItem>>(
STORAGE_CLASS_API({ clusterID: params.clusterID }),
{
method: 'GET',
params: omitPathParams(params),
cancelToken: options?.token,
headers: {
skipErrorHandler: 'true'
}
}
);
return request<Global.PageResponse<StorageClassItem>>(STORAGE_CLASS_API, {
method: 'GET',
params,
cancelToken: options?.token
});
}
+6 -83
View File
@@ -20,90 +20,13 @@ export const status: Record<string, StatusType> = {
[StoragePhaseValueMap.Pending]: StatusMaps.transitioning
};
export const StorageTypeValueMap = {
Local: 'local',
Shared: 'shared',
Object: 'object'
};
export const StorageTypeLabelMap: Record<string, string> = {
[StorageTypeValueMap.Local]: 'gpuservice.storage.type.local',
[StorageTypeValueMap.Shared]: 'gpuservice.storage.type.shared',
[StorageTypeValueMap.Object]: 'gpuservice.storage.type.object'
};
export const StorageTypeOptions = [
{
label: StorageTypeLabelMap[StorageTypeValueMap.Local],
value: StorageTypeValueMap.Local,
locale: true
},
{
label: StorageTypeLabelMap[StorageTypeValueMap.Shared],
value: StorageTypeValueMap.Shared,
locale: true
},
{
label: StorageTypeLabelMap[StorageTypeValueMap.Object],
value: StorageTypeValueMap.Object,
locale: true
}
];
export const AccessModeValueMap = {
ReadWriteOnce: 'ReadWriteOnce',
ReadOnlyMany: 'ReadOnlyMany',
ReadWriteMany: 'ReadWriteMany',
ReadWriteOncePod: 'ReadWriteOncePod'
};
export const AccessModeOptions = [
{
label: 'ReadWriteOnce',
value: AccessModeValueMap.ReadWriteOnce
},
{
label: 'ReadWriteMany',
value: AccessModeValueMap.ReadWriteMany
},
{
label: 'ReadOnlyMany',
value: AccessModeValueMap.ReadOnlyMany
},
{
label: 'ReadWriteOncePod',
value: AccessModeValueMap.ReadWriteOncePod
}
];
export const ReClaimPolicyValueMap = {
Retain: 'Retain',
Delete: 'Delete',
Recycle: 'Recycle'
};
export const ReClaimPolicyOptions = [
{
label: 'Retain',
value: ReClaimPolicyValueMap.Retain
},
{
label: 'Delete',
value: ReClaimPolicyValueMap.Delete
},
{
label: 'Recycle',
value: ReClaimPolicyValueMap.Recycle
}
];
export const rowActionList = [
// {
// label: 'common.button.edit',
// key: 'edit',
// locale: true,
// icon: icons.EditOutlined
// },
{
label: 'common.button.edit',
key: 'edit',
locale: true,
icon: icons.EditOutlined
},
{
label: 'common.button.delete',
key: 'delete',
+55 -47
View File
@@ -1,60 +1,68 @@
export type AccessModeType =
| 'ReadOnlyMany'
| 'ReadWriteMany'
| 'ReadWriteOnce'
| 'ReadWriteOncePod';
export type ReClaimPolicyType = 'Retain' | 'Delete' | 'Recycle';
export interface FormData {
metadata: {
name: string;
namespace: string;
};
spec: {
capacity: string;
type: string;
};
}
export interface ManagedField {
manager: string;
operation: string;
apiVersion: string;
time: string;
fieldsType: string;
fieldsV1: Record<string, any>;
}
export interface Metadata {
name: string;
namespace?: string;
uid: string;
resourceVersion: string;
creationTimestamp: string;
annotations?: Record<string, string>;
finalizers?: string[];
managedFields?: ManagedField[];
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
spec: {
type: string;
capacity: string;
};
}
// Per `GPUInstancePersistentVolumeUpdate`, only displayName / description /
// owner_principal_id are mutable post-create.
export interface UpdateData {
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
}
export interface ListItem {
metadata: Metadata;
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
spec: {
type: string;
capacity: string;
accessMode: AccessModeType;
};
status: {
phaseMessage?: string;
phase: 'Available' | 'Unavailable' | 'Pending';
volume: {
name: string;
};
};
status?: {
phase?: string | null;
} | null;
}
export interface StorageClassNFS {
server: string;
share: string;
subDirectory?: string | null;
mountPermissions?: string | null;
mountOptions?: string[] | null;
}
export interface StorageClassS3 {
endpoint: string;
region?: string | null;
insecure?: boolean | null;
accessKey?: string | null;
bucket?: string | null;
mountOptions?: string[] | null;
}
export interface StorageClassItem {
metadata: Metadata;
provisioner: string;
reclaimPolicy: 'Delete' | 'Retain';
volumeBindingMode: 'Immediate' | 'WaitForFirstConsumer';
id: number;
created_at: string;
updated_at: string;
deleted_at?: string | null;
owner_principal_id?: number | null;
displayName?: string | null;
description?: string | null;
name: string;
spec: {
nfs?: StorageClassNFS | null;
s3?: StorageClassS3 | null;
};
}
+21 -3
View File
@@ -3,11 +3,13 @@ import {
Input as CInput,
InputNumber,
Select as SealSelect,
Textarea,
useAppUtils
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Flex, Form } from 'antd';
import { useEffect } from 'react';
import OwnerPrincipalIdField from '../../../_components/owner-principal-id-field';
import { FormData } from '../config/types';
import useQueryStorageClass from '../services/use-query-storage-class';
@@ -18,14 +20,15 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
useEffect(() => {
if (open) {
fetchData({});
fetchData({ page: 1, perPage: 100 });
}
}, [open]);
return (
<>
<OwnerPrincipalIdField name="owner_principal_id" />
<Form.Item<FormData>
name={['metadata', 'name']}
name="name"
rules={[
{
required: true,
@@ -43,6 +46,19 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
required
/>
</Form.Item>
<Form.Item<FormData> name="displayName">
<CInput.Input
trim={false}
label={intl.formatMessage({ id: 'common.table.displayName' })}
/>
</Form.Item>
<Form.Item<FormData> name="description">
<Textarea
trim={false}
scaleSize={true}
label={intl.formatMessage({ id: 'common.table.description' })}
/>
</Form.Item>
<Flex gap={16}>
<div style={{ flex: 1 }}>
<Form.Item<FormData>
@@ -79,7 +95,9 @@ const Basic = ({ open, action }: { open: boolean; action: string }) => {
>
<InputNumber
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storage.capacity' })}
label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume.capacity'
})}
required
/>
</Form.Item>
+4 -12
View File
@@ -10,19 +10,12 @@ interface StorageFormProps {
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
namespace?: string;
onFinish: (values: FormData) => Promise<void>;
}
const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
(props, ref) => {
const {
action,
currentData,
open,
namespace = 'default',
onFinish
} = props;
const { action, currentData, open, onFinish } = props;
const [form] = Form.useForm<FormData>();
useEffect(() => {
@@ -33,10 +26,9 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
if (action === PageAction.EDIT && currentData) {
form.setFieldsValue({
metadata: {
name: currentData.metadata?.name,
namespace: currentData.metadata?.namespace
},
name: currentData.name,
displayName: currentData.displayName,
description: currentData.description,
spec: {
capacity: currentData.spec?.capacity,
type: currentData.spec?.type
@@ -0,0 +1,52 @@
import { PageAction } from '@/config';
import { PageActionType } from '@/config/types';
import useBodyScroll from '@/hooks/use-body-scroll';
import { useState } from 'react';
import { ListItem } from '../config/types';
const useCreateStorageModal = () => {
const { saveScrollHeight, restoreScrollHeight } = useBodyScroll();
const [openModalStatus, setOpenModalStatus] = useState<{
open: boolean;
action: PageActionType;
currentData?: ListItem | null;
title: string;
}>({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
const openModal = (
action: PageActionType,
title: string,
row?: ListItem | null
) => {
setOpenModalStatus({
open: true,
title,
action,
currentData: row ?? null
});
saveScrollHeight();
};
const closeModal = () => {
setOpenModalStatus({
open: false,
action: PageAction.CREATE,
currentData: null,
title: ''
});
restoreScrollHeight();
};
return {
openStorageModalStatus: openModalStatus,
openStorageModal: openModal,
closeStorageModal: closeModal
};
};
export default useCreateStorageModal;
@@ -3,12 +3,7 @@ import { useIntl } from '@umijs/max';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import {
rowActionList,
status,
StoragePhaseLabelMap,
StorageTypeLabelMap
} from '../config';
import { rowActionList, status, StoragePhaseLabelMap } from '../config';
import { ListItem } from '../config/types';
interface ColumnsHookProps {
@@ -25,15 +20,15 @@ const useStorageColumns = ({
return [
{
title: intl.formatMessage({ id: 'common.table.name' }),
dataIndex: ['metadata', 'name'],
dataIndex: 'name',
key: 'name',
sorter: false,
ellipsis: {
showTitle: false
},
render: (text: string) => (
render: (text: string, record: ListItem) => (
<AutoTooltip ghost style={{ maxWidth: 360 }}>
<span className="text-primary">{text}</span>
<span className="text-primary">{record.displayName || text}</span>
</AutoTooltip>
)
},
@@ -42,10 +37,7 @@ const useStorageColumns = ({
dataIndex: ['spec', 'type'],
key: 'type',
sorter: false,
render: (value: string) =>
StorageTypeLabelMap[value]
? intl.formatMessage({ id: StorageTypeLabelMap[value] })
: value || '-'
render: (value: string) => value || '-'
},
{
title: intl.formatMessage({ id: 'gpuservice.storage.capacity' }),
@@ -59,20 +51,22 @@ const useStorageColumns = ({
dataIndex: ['status', 'phase'],
key: 'status',
sorter: false,
render: (value: string, record: ListItem) => (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value,
message: record.status?.phaseMessage || ''
}}
></StatusTag>
)
render: (value: string) =>
value ? (
<StatusTag
statusValue={{
status: status[value],
text: StoragePhaseLabelMap[value] || value
}}
></StatusTag>
) : (
'-'
)
},
{
title: intl.formatMessage({ id: 'common.table.createTime' }),
dataIndex: ['metadata', 'creationTimestamp'],
key: 'creationTimestamp',
dataIndex: 'created_at',
key: 'created_at',
sorter: false,
ellipsis: {
showTitle: false
+43 -193
View File
@@ -1,85 +1,26 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import { PaginationKey, TABLE_SORT_DIRECTIONS } from '@/config/settings';
import type { PageActionType } from '@/config/types';
import useTableFetch from '@/hooks/use-table-fetch';
import { ProviderValueMap } from '@/pages/cluster-management/config';
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
import {
BaseSelect,
DeleteModal,
FilterBar,
IconFont,
NoResult
} from '@gpustack/core-ui';
import { DeleteModal, FilterBar, IconFont, NoResult } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd';
import { useAtom } from 'jotai';
import { ConfigProvider, message, Table } from 'antd';
import _ from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { PageContainerInner } from '../../_components/page-box';
import PageBox from '../../_components/page-box';
import {
deleteGPUServiceStorage,
GPU_SERVICE_STORAGE_API,
queryGPUServiceStorage
} from './apis';
import AddModal from './components/add-modal';
import { FormData, ListItem } from './config/types';
import useCreateStorageModal from './hooks/use-create-storage-modal';
import useStorageColumns from './hooks/use-storage-columns';
import useCreateStorage from './services/use-create-storage';
import useUpdateStorage from './services/use-update-storage';
const GPUServiceStorage: React.FC = () => {
const intl = useIntl();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view falls back to the cluster's owner Org name —
// see :func:`getCurrentOrgNamespace`.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const deleteStorage = useCallback(
(id: number) => deleteGPUServiceStorage({ namespace, clusterID, id }),
[namespace, clusterID]
);
const fetchStorage = useCallback(
async (
params: any,
options?: any
): Promise<Global.PageResponse<ListItem>> => {
const effectiveClusterID = params.cluster_id ?? clusterID;
if (!effectiveClusterID) {
return {
items: [],
pagination: {
total: 0,
totalPage: 0,
page: 1,
perPage: params.perPage || 10
}
} as Global.PageResponse<ListItem>;
}
const res = await queryGPUServiceStorage(
{ ...params, namespace, clusterID: effectiveClusterID },
options
);
const total = res?.items?.length ?? 0;
const perPage = params.perPage || 10;
return {
items: res?.items ?? [],
pagination: {
total,
totalPage: Math.ceil(total / perPage),
page: params.page || 1,
perPage
}
};
},
[namespace, clusterID]
);
const {
dataSource,
@@ -92,116 +33,55 @@ const GPUServiceStorage: React.FC = () => {
fetchData,
handlePageChange,
handleTableChange,
handleQueryChange,
handleSearch,
handleNameChange
} = useTableFetch<ListItem>({
key: PaginationKey.Storage,
fetchAPI: fetchStorage,
deleteAPI: deleteStorage,
fetchAPI: queryGPUServiceStorage,
deleteAPI: deleteGPUServiceStorage,
watch: false,
polling: true,
API: GPU_SERVICE_STORAGE_API({ namespace, clusterID }),
API: GPU_SERVICE_STORAGE_API,
contentForDelete: intl.formatMessage({ id: 'gpuservice.storage' })
});
const { fetchData: createStorage } = useCreateStorage();
const { fetchData: updateStorage } = useUpdateStorage();
const {
fetchClusterList,
cancelRequest: cancelClusterRequest,
clusterList
} = useQueryClusterList();
const k8sClusterList = useMemo(
() =>
clusterList.filter(
(item) => item.provider === ProviderValueMap.Kubernetes
),
[clusterList]
);
const [openAddModalStatus, setOpenAddModalStatus] = useState<{
action: PageActionType;
open: boolean;
title: string;
currentData?: ListItem | null;
}>({
action: PageAction.CREATE,
title: '',
open: false,
currentData: null
});
useEffect(() => {
fetchClusterList({ page: -1 }).then((clusters) => {
const k8sClusters = clusters.filter(
(item: any) => item.provider === ProviderValueMap.Kubernetes
);
if (k8sClusters.length === 0) {
return;
}
const storedCluster = k8sClusters.find(
(item: any) => item.id === currentCluster?.id
);
const targetCluster = storedCluster ?? k8sClusters[0];
if (!storedCluster) {
setCurrentCluster({
...targetCluster,
label: targetCluster.name,
value: targetCluster.id
});
}
handleQueryChange({
cluster_id: targetCluster.id,
page: 1
});
});
return () => {
cancelClusterRequest();
};
}, []);
const { openStorageModalStatus, openStorageModal, closeStorageModal } =
useCreateStorageModal();
const handleAddStorage = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: intl.formatMessage({ id: 'gpuservice.storage.add' }),
open: true,
currentData: null
});
openStorageModal(
PageAction.CREATE,
intl.formatMessage({ id: 'gpuservice.storage.add' })
);
};
const handleEditStorage = (row: ListItem) => {
setOpenAddModalStatus({
action: PageAction.EDIT,
title: intl.formatMessage({ id: 'gpuservice.storage.edit' }),
open: true,
currentData: row
});
};
const closeModal = () => {
setOpenAddModalStatus({
action: PageAction.CREATE,
title: '',
open: false,
currentData: null
});
openStorageModal(
PageAction.EDIT,
intl.formatMessage({ id: 'gpuservice.storage.edit' }),
row
);
};
const handleModalOk = async (data: FormData) => {
try {
if (openAddModalStatus.action === PageAction.EDIT) {
if (openStorageModalStatus.action === PageAction.EDIT) {
await updateStorage({
id: openAddModalStatus.currentData!.metadata?.name as any,
data
id: openStorageModalStatus.currentData!.id,
data: {
owner_principal_id: data.owner_principal_id,
displayName: data.displayName,
description: data.description
}
});
} else {
await createStorage({ data });
}
fetchData();
closeModal();
closeStorageModal();
message.success(intl.formatMessage({ id: 'common.message.success' }));
} catch (error) {
message.error(intl.formatMessage({ id: 'common.message.fail' }));
@@ -214,23 +94,11 @@ const GPUServiceStorage: React.FC = () => {
} else if (val === 'delete') {
handleDelete({
...row,
name: row.metadata?.name,
id: row.metadata?.name as any
id: row.id
});
}
});
const handleClusterChange = (value: number) => {
const cluster = k8sClusterList.find((item) => item.value === value);
if (cluster) {
setCurrentCluster(cluster);
}
handleQueryChange({
cluster_id: value,
page: 1
});
};
const renderEmpty = (type?: string) => {
if (type !== 'Table') return;
return (
@@ -260,38 +128,13 @@ const GPUServiceStorage: React.FC = () => {
return (
<>
<PageContainerInner
leftContent={
<Flex align="center">
<span>{intl.formatMessage({ id: 'menu.gpuService.storage' })}</span>
<Divider
orientation="vertical"
style={{
marginLeft: 16
}}
/>
<BaseSelect
variant="borderless"
value={queryParams.cluster_id}
options={k8sClusterList}
style={{ minWidth: 120, fontWeight: 500 }}
onChange={handleClusterChange}
></BaseSelect>
</Flex>
}
>
<PageBox>
<FilterBar
marginBottom={22}
marginTop={30}
showSelect={false}
selectOptions={k8sClusterList}
select={{ showSearch: true }}
selectHolder={intl.formatMessage({
id: 'gpuservice.storage.filter.cluster'
})}
buttonText={intl.formatMessage({ id: 'gpuservice.storage.add' })}
handleSearch={handleSearch}
handleSelectChange={handleClusterChange}
handleDeleteByBatch={handleDeleteBatch}
handleClickPrimary={handleAddStorage}
handleInputChange={handleNameChange}
@@ -309,18 +152,25 @@ const GPUServiceStorage: React.FC = () => {
}}
sortDirections={TABLE_SORT_DIRECTIONS}
showSorterTooltip={false}
rowKey={(record) => record.metadata?.name as any}
rowKey={(record) => record.id}
onChange={handleTableChange}
pagination={false}
pagination={{
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/>
</ConfigProvider>
</PageContainerInner>
</PageBox>
<AddModal
open={openAddModalStatus.open}
action={openAddModalStatus.action}
title={openAddModalStatus.title}
data={openAddModalStatus.currentData}
onCancel={closeModal}
open={openStorageModalStatus.open}
action={openStorageModalStatus.action}
title={openStorageModalStatus.title}
data={openStorageModalStatus.currentData}
onCancel={closeStorageModal}
onOk={handleModalOk}
/>
<DeleteModal ref={modalRef} />
@@ -1,9 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { createGPUServiceStorage } from '../apis';
import { FormData, ListItem } from '../config/types';
@@ -12,31 +8,10 @@ interface CreateStorageParams {
}
export default function useCreateStorage() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: CreateStorageParams, option?: any) =>
createGPUServiceStorage(
{
namespace,
clusterID,
data: {
apiVersion,
kind: KindMapping.instancePersistentVolume,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: CreateStorageParams) =>
createGPUServiceStorage({ data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -1,28 +1,23 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { useQueryDataList } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryStorageClass } from '../apis';
import { StorageClassItem } from '../config/types';
export default function useQueryStorageClass() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
const fetchList = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryStorageClass({ ...params, clusterID }, options),
[clusterID]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryStorageClass(params, options),
[]
);
const { dataList, loading, cancelRequest, fetchData } = useQueryDataList<
StorageClassItem,
Global.K8sSearchParams
Global.SearchParams
>({
key: 'storageClass',
fetchList,
getLabel: (item) => item?.metadata.name,
getValue: (item) => item?.metadata.name
getLabel: (item) => item?.displayName || item?.name,
getValue: (item) => item?.name
});
return {
@@ -1,27 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { queryGPUServiceStorage } from '../apis';
import { ListItem } from '../config/types';
export default function useQueryStorage() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: Global.K8sSearchParams = {}, options?: any) =>
queryGPUServiceStorage({ ...params, namespace, clusterID }, options),
[namespace, clusterID]
(params: Global.SearchParams = { page: 1, perPage: 100 }, options?: any) =>
queryGPUServiceStorage(params, options),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
Global.K8sPageResponse<ListItem>,
Global.K8sSearchParams
Global.PageResponse<ListItem>,
Global.SearchParams
>({
fetchDetail,
key: 'storage'
@@ -1,44 +1,18 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { apiVersion, KindMapping } from '../../constants';
import { updateGPUServiceStorage } from '../apis';
import { FormData, ListItem } from '../config/types';
import { ListItem, UpdateData } from '../config/types';
interface UpdateStorageParams {
id: number;
data: FormData;
data: UpdateData;
}
export default function useUpdateStorage() {
const currentCluster = useAtomValue(currentClusterAtom);
const clusterID = currentCluster?.id;
// Admin "All" view has no Org context — fall back to the
// selected cluster's owner Org name for the K8s namespace.
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const fetchDetail = useCallback(
(params: UpdateStorageParams, option?: any) =>
updateGPUServiceStorage(
{
namespace,
clusterID,
id: params.id,
data: {
apiVersion,
kind: KindMapping.instancePersistentVolume,
metadata: {
name: params.data.metadata.name,
namespace
},
spec: params.data.spec
}
},
option
),
[namespace, clusterID]
(params: UpdateStorageParams) =>
updateGPUServiceStorage({ id: params.id, data: params.data }),
[]
);
const { detailData, loading, cancelRequest, fetchData } = useQueryData<
@@ -58,7 +58,7 @@ const Basic: React.FC<BasicProps> = ({
<Flex gap={4} align="center">
{label}
<span>
({intl.formatMessage({ id: 'common.remaining' }, { count: max })})
({intl.formatMessage({ id: 'common.max' }, { count: max })})
</span>
</Flex>
);
@@ -126,6 +126,7 @@ const Basic: React.FC<BasicProps> = ({
id: 'gpuservice.template.displayName'
})}
showCount
trim={false}
maxLength={63}
/>
</Form.Item>