feat: add instance recreate action

This commit is contained in:
jialin
2026-05-15 16:22:07 +08:00
committed by jialin
parent c6e7075bf5
commit 87c8a3a64d
36 changed files with 616 additions and 106 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
"@ant-design/pro-components": "3.1.0-0", "@ant-design/pro-components": "3.1.0-0",
"@antv/g6": "^5.0.51", "@antv/g6": "^5.0.51",
"@braintree/sanitize-url": "^7.1.1", "@braintree/sanitize-url": "^7.1.1",
"@gpustack/core-ui": "^1.0.11", "@gpustack/core-ui": "^1.0.12",
"@huggingface/gguf": "^0.1.7", "@huggingface/gguf": "^0.1.7",
"@huggingface/hub": "^0.15.1", "@huggingface/hub": "^0.15.1",
"@huggingface/tasks": "^0.11.6", "@huggingface/tasks": "^0.11.6",
+5 -5
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.1.1 specifier: ^7.1.1
version: 7.1.2 version: 7.1.2
'@gpustack/core-ui': '@gpustack/core-ui':
specifier: ^1.0.11 specifier: ^1.0.12
version: 1.0.11(czdvzceysqw7iv6pct2ucnb23e) version: 1.0.12(czdvzceysqw7iv6pct2ucnb23e)
'@huggingface/gguf': '@huggingface/gguf':
specifier: ^0.1.7 specifier: ^0.1.7
version: 0.1.18 version: 0.1.18
@@ -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} 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 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.11': '@gpustack/core-ui@1.0.12':
resolution: {integrity: sha512-1NpR0oT8e93BAMEtzAR9XZkOGercohG3P/QwIiEclHDaILbReUeGWoT1t1sP5yazu4v7uXRucV4rhPApLQ5gBA==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.11.tgz} resolution: {integrity: sha512-2nwQ7i5UXO0Slm2mrZxxx4C8+932xBw2Q9laqZ+OLZscBq7070YEty383MGeujTnIKwrijBcP7L3boCPr8cyWQ==, tarball: https://registry.npmjs.org/@gpustack/core-ui/-/core-ui-1.0.12.tgz}
peerDependencies: peerDependencies:
'@ant-design/icons': '>=6.0.0' '@ant-design/icons': '>=6.0.0'
'@ant-design/pro-components': 3.1.0-0 '@ant-design/pro-components': 3.1.0-0
@@ -10790,7 +10790,7 @@ snapshots:
'@formatjs/intl-utils@2.3.0': {} '@formatjs/intl-utils@2.3.0': {}
'@gpustack/core-ui@1.0.11(czdvzceysqw7iv6pct2ucnb23e)': '@gpustack/core-ui@1.0.12(czdvzceysqw7iv6pct2ucnb23e)':
dependencies: dependencies:
'@ant-design/icons': 6.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@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) '@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)
+2
View File
@@ -52,6 +52,8 @@ export default {
'common.button.authorize': 'Role Authorization', 'common.button.authorize': 'Role Authorization',
'common.button.confirm': 'Confirm', 'common.button.confirm': 'Confirm',
'common.button.viewlog': 'View Logs', 'common.button.viewlog': 'View Logs',
'common.button.viewevent': 'View Events',
'common.button.recreate': 'Recreate',
'common.table.operation': 'Operations', 'common.table.operation': 'Operations',
'common.table.createTime': 'Created', 'common.table.createTime': 'Created',
'common.table.updateTime': 'Updated', 'common.table.updateTime': 'Updated',
+11
View File
@@ -25,6 +25,7 @@ export default {
'gpuservice.template.ports.name': 'Name', 'gpuservice.template.ports.name': 'Name',
'gpuservice.template.ports.name.max': 'gpuservice.template.ports.name.max':
'Port name cannot exceed 16 characters.', 'Port name cannot exceed 16 characters.',
'gpuservice.template.ports.name.duplicate': 'Port names must be unique.',
'gpuservice.template.env': 'Environment Variables', 'gpuservice.template.env': 'Environment Variables',
'gpuservice.template.env.add': 'Add Environment Variable', 'gpuservice.template.env.add': 'Add Environment Variable',
'gpuservice.template.env.invalid': 'gpuservice.template.env.invalid':
@@ -66,6 +67,16 @@ export default {
'gpuservice.instance.template.mount': 'Mount', 'gpuservice.instance.template.mount': 'Mount',
'gpuservice.instance.connect': 'Connect', 'gpuservice.instance.connect': 'Connect',
'gpuservice.instance.connect.copySshCommand': 'Copy SSH Command', 'gpuservice.instance.connect.copySshCommand': 'Copy SSH Command',
'gpuservice.instance.event.reason': 'Reason',
'gpuservice.instance.event.message': 'Message',
'gpuservice.instance.event.source': 'Source',
'gpuservice.instance.event.count': 'Count',
'gpuservice.instance.event.lastSeen': 'Last Seen',
'gpuservice.instance.event.recentHourTip':
'Only events from the last hour are shown',
'gpuservice.instance.recreate.confirm.title': 'Confirm recreation',
'gpuservice.instance.recreate.confirm.content':
'The current instance will be deleted first, then recreated with the current configuration.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Storage', 'gpuservice.storage': 'Storage',
'gpuservice.storage.add': 'Add Storage', 'gpuservice.storage.add': 'Add Storage',
'gpuservice.storage.edit': 'Edit Storage', 'gpuservice.storage.edit': 'Edit Storage',
+2
View File
@@ -52,6 +52,8 @@ export default {
'common.button.authorize': 'ロール認可', 'common.button.authorize': 'ロール認可',
'common.button.confirm': '確認', 'common.button.confirm': '確認',
'common.button.viewlog': 'ログを表示', 'common.button.viewlog': 'ログを表示',
'common.button.viewevent': 'イベントを表示',
'common.button.recreate': '再作成',
'common.table.operation': '操作', 'common.table.operation': '操作',
'common.table.createTime': '作成日時', 'common.table.createTime': '作成日時',
'common.table.updateTime': '更新日時', 'common.table.updateTime': '更新日時',
+11
View File
@@ -24,6 +24,7 @@ export default {
'gpuservice.template.ports.name': '名前', 'gpuservice.template.ports.name': '名前',
'gpuservice.template.ports.name.max': 'gpuservice.template.ports.name.max':
'ポート名は 16 文字以内で入力してください。', 'ポート名は 16 文字以内で入力してください。',
'gpuservice.template.ports.name.duplicate': 'ポート名は重複できません。',
'gpuservice.template.env': '環境変数', 'gpuservice.template.env': '環境変数',
'gpuservice.template.env.add': '環境変数を追加', 'gpuservice.template.env.add': '環境変数を追加',
'gpuservice.template.env.invalid': '環境変数を完成させてください。', 'gpuservice.template.env.invalid': '環境変数を完成させてください。',
@@ -64,6 +65,16 @@ export default {
'gpuservice.instance.template.mount': 'マウント', 'gpuservice.instance.template.mount': 'マウント',
'gpuservice.instance.connect': '接続', 'gpuservice.instance.connect': '接続',
'gpuservice.instance.connect.copySshCommand': 'SSH コマンドをコピー', 'gpuservice.instance.connect.copySshCommand': 'SSH コマンドをコピー',
'gpuservice.instance.event.reason': '理由',
'gpuservice.instance.event.message': 'メッセージ',
'gpuservice.instance.event.source': 'ソース',
'gpuservice.instance.event.count': '回数',
'gpuservice.instance.event.lastSeen': '最終発生',
'gpuservice.instance.event.recentHourTip':
'直近 1 時間のイベントのみ表示されます',
'gpuservice.instance.recreate.confirm.title': '再作成を確認しますか',
'gpuservice.instance.recreate.confirm.content':
'現在のインスタンスを削除した後、現在の構成で再作成します。\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'ストレージ', 'gpuservice.storage': 'ストレージ',
'gpuservice.storage.add': 'ストレージを追加', 'gpuservice.storage.add': 'ストレージを追加',
'gpuservice.storage.edit': 'ストレージを編集', 'gpuservice.storage.edit': 'ストレージを編集',
+2
View File
@@ -52,6 +52,8 @@ export default {
'common.button.authorize': 'Настройка прав', 'common.button.authorize': 'Настройка прав',
'common.button.confirm': 'Подтвердить', 'common.button.confirm': 'Подтвердить',
'common.button.viewlog': 'Просмотр логов', 'common.button.viewlog': 'Просмотр логов',
'common.button.viewevent': 'Просмотр событий',
'common.button.recreate': 'Пересоздать',
'common.table.operation': 'Действия', 'common.table.operation': 'Действия',
'common.table.createTime': 'Создано', 'common.table.createTime': 'Создано',
'common.table.updateTime': 'Обновлено', 'common.table.updateTime': 'Обновлено',
+12
View File
@@ -25,6 +25,8 @@ export default {
'gpuservice.template.ports.name': 'Имя', 'gpuservice.template.ports.name': 'Имя',
'gpuservice.template.ports.name.max': 'gpuservice.template.ports.name.max':
'Имя порта не должно превышать 16 символов.', 'Имя порта не должно превышать 16 символов.',
'gpuservice.template.ports.name.duplicate':
'Имена портов должны быть уникальными.',
'gpuservice.template.env': 'Переменные окружения', 'gpuservice.template.env': 'Переменные окружения',
'gpuservice.template.env.add': 'Добавить переменную окружения', 'gpuservice.template.env.add': 'Добавить переменную окружения',
'gpuservice.template.env.invalid': 'gpuservice.template.env.invalid':
@@ -66,6 +68,16 @@ export default {
'gpuservice.instance.template.mount': 'Монтирование', 'gpuservice.instance.template.mount': 'Монтирование',
'gpuservice.instance.connect': 'Подключение', 'gpuservice.instance.connect': 'Подключение',
'gpuservice.instance.connect.copySshCommand': 'Скопировать команду SSH', 'gpuservice.instance.connect.copySshCommand': 'Скопировать команду SSH',
'gpuservice.instance.event.reason': 'Причина',
'gpuservice.instance.event.message': 'Сообщение',
'gpuservice.instance.event.source': 'Источник',
'gpuservice.instance.event.count': 'Кол-во',
'gpuservice.instance.event.lastSeen': 'Последнее событие',
'gpuservice.instance.event.recentHourTip':
'Отображаются только события за последний час',
'gpuservice.instance.recreate.confirm.title': 'Подтвердить пересоздание',
'gpuservice.instance.recreate.confirm.content':
'Текущий экземпляр будет сначала удалён, а затем пересоздан с текущей конфигурацией.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Хранилище', 'gpuservice.storage': 'Хранилище',
'gpuservice.storage.add': 'Добавить хранилище', 'gpuservice.storage.add': 'Добавить хранилище',
'gpuservice.storage.edit': 'Редактировать хранилище', 'gpuservice.storage.edit': 'Редактировать хранилище',
+2
View File
@@ -52,6 +52,8 @@ export default {
'common.button.authorize': 'Rol Yetkilendirme', 'common.button.authorize': 'Rol Yetkilendirme',
'common.button.confirm': 'Onayla', 'common.button.confirm': 'Onayla',
'common.button.viewlog': 'Günlükleri Görüntüle', 'common.button.viewlog': 'Günlükleri Görüntüle',
'common.button.viewevent': 'Olayları Görüntüle',
'common.button.recreate': 'Yeniden Oluştur',
'common.table.operation': 'İşlemler', 'common.table.operation': 'İşlemler',
'common.table.createTime': 'Oluşturulma', 'common.table.createTime': 'Oluşturulma',
'common.table.updateTime': 'Güncellenme', 'common.table.updateTime': 'Güncellenme',
+13
View File
@@ -25,6 +25,8 @@ export default {
'gpuservice.template.ports.name': 'Ad', 'gpuservice.template.ports.name': 'Ad',
'gpuservice.template.ports.name.max': 'gpuservice.template.ports.name.max':
'Bağlantı noktası adı 16 karakterden uzun olamaz.', 'Bağlantı noktası adı 16 karakterden uzun olamaz.',
'gpuservice.template.ports.name.duplicate':
'Bağlantı noktası adları benzersiz olmalıdır.',
'gpuservice.template.env': 'Ortam Değişkenleri', 'gpuservice.template.env': 'Ortam Değişkenleri',
'gpuservice.template.env.add': 'Ortam Değişkeni Ekle', 'gpuservice.template.env.add': 'Ortam Değişkeni Ekle',
'gpuservice.template.env.invalid': 'Ortam değişkenlerini eksiksiz doldurun.', 'gpuservice.template.env.invalid': 'Ortam değişkenlerini eksiksiz doldurun.',
@@ -65,6 +67,17 @@ export default {
'gpuservice.instance.template.mount': 'Bağlama', 'gpuservice.instance.template.mount': 'Bağlama',
'gpuservice.instance.connect': 'Bağlan', 'gpuservice.instance.connect': 'Bağlan',
'gpuservice.instance.connect.copySshCommand': 'SSH Komutunu Kopyala', 'gpuservice.instance.connect.copySshCommand': 'SSH Komutunu Kopyala',
'gpuservice.instance.event.reason': 'Neden',
'gpuservice.instance.event.message': 'Mesaj',
'gpuservice.instance.event.source': 'Kaynak',
'gpuservice.instance.event.count': 'Sayı',
'gpuservice.instance.event.lastSeen': 'Son Görülen',
'gpuservice.instance.event.recentHourTip':
'Yalnızca son bir saatteki olaylar gösterilir',
'gpuservice.instance.recreate.confirm.title':
'Yeniden oluşturma onaylansın mı',
'gpuservice.instance.recreate.confirm.content':
'Mevcut örnek önce silinecek, ardından mevcut yapılandırmayla yeniden oluşturulacaktır.\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': 'Depolama', 'gpuservice.storage': 'Depolama',
'gpuservice.storage.add': 'Depolama Ekle', 'gpuservice.storage.add': 'Depolama Ekle',
'gpuservice.storage.edit': 'Depolamayı Düzenle', 'gpuservice.storage.edit': 'Depolamayı Düzenle',
+2
View File
@@ -50,6 +50,8 @@ export default {
'common.button.authorize': '角色授权', 'common.button.authorize': '角色授权',
'common.button.confirm': '确定', 'common.button.confirm': '确定',
'common.button.viewlog': '查看日志', 'common.button.viewlog': '查看日志',
'common.button.viewevent': '查看事件',
'common.button.recreate': '重新创建',
'common.table.operation': '操作', 'common.table.operation': '操作',
'common.table.createTime': '创建时间', 'common.table.createTime': '创建时间',
'common.table.updateTime': '更新时间', 'common.table.updateTime': '更新时间',
+10
View File
@@ -22,6 +22,7 @@ export default {
'gpuservice.template.ports.invalid': '请填写完整的端口配置', 'gpuservice.template.ports.invalid': '请填写完整的端口配置',
'gpuservice.template.ports.name': '名称', 'gpuservice.template.ports.name': '名称',
'gpuservice.template.ports.name.max': '端口名称不能超过 16 个字符', 'gpuservice.template.ports.name.max': '端口名称不能超过 16 个字符',
'gpuservice.template.ports.name.duplicate': '端口名称不能重复',
'gpuservice.template.env': '环境变量', 'gpuservice.template.env': '环境变量',
'gpuservice.template.env.add': '添加环境变量', 'gpuservice.template.env.add': '添加环境变量',
'gpuservice.template.env.invalid': '请填写完整的环境变量', 'gpuservice.template.env.invalid': '请填写完整的环境变量',
@@ -61,6 +62,15 @@ export default {
'gpuservice.instance.template.mount': '挂载', 'gpuservice.instance.template.mount': '挂载',
'gpuservice.instance.connect': '连接', 'gpuservice.instance.connect': '连接',
'gpuservice.instance.connect.copySshCommand': '复制 SSH 命令', 'gpuservice.instance.connect.copySshCommand': '复制 SSH 命令',
'gpuservice.instance.event.reason': '原因',
'gpuservice.instance.event.message': '消息',
'gpuservice.instance.event.source': '来源',
'gpuservice.instance.event.count': '次数',
'gpuservice.instance.event.lastSeen': '最近发生',
'gpuservice.instance.event.recentHourTip': '仅显示最近一小时的事件。',
'gpuservice.instance.recreate.confirm.title': '确认重新创建',
'gpuservice.instance.recreate.confirm.content':
'系统将先删除当前实例,然后使用当前配置重新创建。\n <span style="font-size: 13px;font-weight: 700">{name}</span>',
'gpuservice.storage': '存储', 'gpuservice.storage': '存储',
'gpuservice.storage.add': '添加存储', 'gpuservice.storage.add': '添加存储',
'gpuservice.storage.edit': '编辑存储', 'gpuservice.storage.edit': '编辑存储',
+29 -8
View File
@@ -17,8 +17,27 @@ export const GPU_SERVICE_INSTANCES_API = (params: {
export const GPU_SERVICE_INSTANCES_TYPE_API = (params: { export const GPU_SERVICE_INSTANCES_TYPE_API = (params: {
clusterID?: number; clusterID?: number;
}) => }) => {
`/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/instancetypes`; return `/clusters/${params.clusterID}/proxy/apis/worker.gpustack.ai/v1/instancetypes`;
};
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`;
};
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`;
};
// =========== Instances ===========
export async function queryGPUServiceInstances( export async function queryGPUServiceInstances(
params: Global.K8sSearchParams & { params: Global.K8sSearchParams & {
@@ -147,10 +166,11 @@ export async function queryGPUServiceInstanceEvents(
return; return;
} }
return request<InstanceEvents>( return request<InstanceEvents>(
`${GPU_SERVICE_INSTANCES_API({ `${GPU_SERVICE_INSTANCES_EVENTS_API({
namespace: params.namespace, namespace: params.namespace,
clusterID: params.clusterID clusterID: params.clusterID,
})}/${params.name}/events`, name: params.name
})}`,
{ {
method: 'GET', method: 'GET',
params: omitPathParams(_.omit(params, ['name'])), params: omitPathParams(_.omit(params, ['name'])),
@@ -171,10 +191,11 @@ export async function queryGPUServiceInstanceLog(
return; return;
} }
return request<InstanceLog>( return request<InstanceLog>(
`${GPU_SERVICE_INSTANCES_API({ `${GPU_SERVICE_INSTANCES_LOG_API({
namespace: params.namespace, namespace: params.namespace,
clusterID: params.clusterID clusterID: params.clusterID,
})}/${params.name}/log`, name: params.name
})}`,
{ {
method: 'GET', method: 'GET',
params: omitPathParams(_.omit(params, ['name'])), params: omitPathParams(_.omit(params, ['name'])),
@@ -135,7 +135,7 @@ const InstanceTypeItem: React.FC<InstanceTypeItemProps> = ({
<> <>
<span className="dot"></span> <span className="dot"></span>
<span className="meta-item"> <span className="meta-item">
<IconFont type="icon-cube" className="meta-icon" /> <IconFont type="icon-database" className="meta-icon" />
<Flex align="center" gap={4}> <Flex align="center" gap={4}>
<span> <span>
{intl.formatMessage({ {intl.formatMessage({
@@ -0,0 +1,212 @@
import { createAxiosToken } from '@/hooks/use-chunk-request';
import { ReloadOutlined } from '@ant-design/icons';
import {
AlertBlockInfo,
AutoTooltip,
ScrollerModal,
StatusTag
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Table } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { queryGPUServiceInstanceEvents } from '../apis';
import { InstanceEventItem } from '../config/types';
type ViewEventsModalProps = {
open: boolean;
name: string;
namespace: string;
clusterID?: number;
onCancel: () => void;
};
const formatRelative = (text?: string) => (text ? dayjs(text).fromNow() : '-');
const eventTypeStatus: Record<string, 'success' | 'warning' | 'error'> = {
Normal: 'success',
Warning: 'warning'
};
const ViewEventsModal: React.FC<ViewEventsModalProps> = (props) => {
const intl = useIntl();
const { open, onCancel, name, namespace, clusterID } = props || {};
const [events, setEvents] = useState<InstanceEventItem[]>([]);
const [loading, setLoading] = useState(false);
const tokenRef = useRef<ReturnType<typeof createAxiosToken> | null>(null);
const fetchEvents = useCallback(async () => {
if (!name || !clusterID || !namespace) return;
tokenRef.current?.cancel?.();
const source = createAxiosToken();
tokenRef.current = source;
setLoading(true);
try {
const res = await queryGPUServiceInstanceEvents(
{ name, namespace, clusterID },
{ token: source.token }
);
setEvents(res?.items ?? []);
} catch (e) {
// request cancellation or network failure — keep prior items
} finally {
setLoading(false);
}
}, [name, namespace, clusterID]);
useEffect(() => {
if (open) {
fetchEvents();
} else {
tokenRef.current?.cancel?.();
setEvents([]);
}
return () => {
tokenRef.current?.cancel?.();
};
}, [open, fetchEvents]);
const handleCancel = useCallback(() => {
tokenRef.current?.cancel?.();
onCancel();
}, [onCancel]);
const columns: ColumnsType<InstanceEventItem> = [
{
title: intl.formatMessage({ id: 'common.table.type' }),
dataIndex: 'type',
key: 'type',
width: 110,
render: (value: string) => (
<StatusTag
statusValue={{
status: eventTypeStatus[value] ?? 'inactive',
text: value || '-'
}}
/>
)
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.event.reason' }),
dataIndex: 'reason',
key: 'reason',
width: 180,
ellipsis: { showTitle: false },
render: (text: string) => (
<AutoTooltip ghost style={{ maxWidth: 180 }}>
{text || '-'}
</AutoTooltip>
)
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.event.message' }),
dataIndex: 'message',
key: 'message',
ellipsis: { showTitle: false },
render: (text: string) => <AutoTooltip ghost>{text || '-'}</AutoTooltip>
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.event.source' }),
key: 'source',
width: 200,
ellipsis: { showTitle: false },
render: (_text, record) => {
const src =
record.source?.component ||
record.reportingComponent ||
record.reportingInstance ||
'-';
return (
<AutoTooltip ghost style={{ maxWidth: 200 }}>
{src}
</AutoTooltip>
);
}
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.event.count' }),
dataIndex: 'count',
key: 'count',
width: 80,
render: (value?: number) => value ?? '-'
},
{
title: intl.formatMessage({ id: 'gpuservice.instance.event.lastSeen' }),
key: 'lastTimestamp',
width: 180,
render: (_text, record) =>
formatRelative(
record.lastTimestamp ||
record.series?.lastObservedTime ||
record.eventTime
)
}
];
return (
<ScrollerModal
title={
<span className="flex flex-center" style={{ gap: 8 }}>
<span style={{ fontWeight: 'var(--font-weight-bold)' }}>
{intl.formatMessage({ id: 'common.button.viewevent' })}
</span>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={fetchEvents}
loading={loading}
/>
</span>
}
zIndex={3000}
open={open}
centered={true}
onCancel={handleCancel}
destroyOnHidden={true}
closeIcon={true}
mask={{
closable: false
}}
keyboard={true}
styles={{
wrapper: {
borderRadius: 0
}
}}
width="1000px"
maxContentHeight="100vh"
footer={null}
>
<div style={{ marginBlock: '8px 16px' }}>
<AlertBlockInfo
message={intl.formatMessage({
id: 'gpuservice.instance.event.recentHourTip'
})}
type="warning"
contentStyle={{ paddingInline: 0 }}
></AlertBlockInfo>
</div>
<Table
columns={columns}
className={'scroll-table'}
tableLayout={'auto'}
style={{ width: '100%', minHeight: 400 }}
dataSource={events || []}
rowKey={(record) =>
`${record.metadata?.name}-${record.metadata.namespace}`
}
loading={{
spinning: loading,
size: 'middle'
}}
virtual
scroll={{ y: 'calc(100vh - 80px)' }}
pagination={false}
></Table>
</ScrollerModal>
);
};
export default ViewEventsModal;
@@ -95,6 +95,9 @@ const ViewLogsModal: React.FC<ViewModalProps> = (props) => {
enableScorllLoad={true} enableScorllLoad={true}
isDownloading={false} isDownloading={false}
params={{ params={{
watchable: false,
watch: false,
tailLines: 1000,
follow: true follow: true
}} }}
></LogsViewer> ></LogsViewer>
@@ -52,12 +52,24 @@ export const rowActionList = [
locale: true, locale: true,
icon: React.createElement(IconFont, { type: 'icon-logs' }) icon: React.createElement(IconFont, { type: 'icon-logs' })
}, },
{
label: 'common.button.viewevent',
key: 'viewevent',
locale: true,
icon: icons.ProfileOutlined
},
{ {
label: 'common.button.edit', label: 'common.button.edit',
key: 'edit', key: 'edit',
locale: true, locale: true,
icon: icons.EditOutlined icon: icons.EditOutlined
}, },
{
label: 'common.button.recreate',
key: 'recreate',
locale: true,
icon: icons.RetweetOutlined
},
{ {
label: 'common.button.delete', label: 'common.button.delete',
key: 'delete', key: 'delete',
@@ -56,6 +56,7 @@ export interface InstanceStatus {
}[]; }[];
ports?: { ports?: {
port: number; port: number;
name: string;
nodePort?: number; nodePort?: number;
protocol?: InstanceServicePortProtocol; protocol?: InstanceServicePortProtocol;
}[]; }[];
@@ -1,10 +1,11 @@
import { validateLabelNameRegxFor63 } from '@/config'; import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { PageActionType } from '@/config/types';
import { Input as CInput } from '@gpustack/core-ui'; import { Input as CInput } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
const Basic = () => { const Basic = ({ action }: { action: PageActionType }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<> <>
@@ -25,6 +26,7 @@ const Basic = () => {
]} ]}
> >
<CInput.Input <CInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.instance.name' })} label={intl.formatMessage({ id: 'gpuservice.instance.name' })}
required required
/> />
@@ -11,6 +11,7 @@ import {
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import _ from 'lodash';
import { import {
forwardRef, forwardRef,
useEffect, useEffect,
@@ -50,7 +51,7 @@ const parseQuantityToNumber = (value?: string): number | null => {
const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value)); const match = /^(-?\d+(?:\.\d+)?)/.exec(String(value));
if (!match) return null; if (!match) return null;
const num = Number(match[1]); const num = Number(match[1]);
return Number.isFinite(num) && num > 0 ? num : null; return Number.isFinite(num) && num > 0 ? _.floor(num, 1) : null;
}; };
const TABKeysMap = { const TABKeysMap = {
@@ -92,7 +93,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
const intl = useIntl(); const intl = useIntl();
const [form] = Form.useForm<InstanceFormValues>(); const [form] = Form.useForm<InstanceFormValues>();
const scrollTabsRef = useRef<any>(null); const scrollTabsRef = useRef<any>(null);
const { detailData, fetchData } = useGetSshkey(); const { detailData: sshKeyData, fetchData: fetchSSHData } = useGetSshkey();
const { getScrollElementScrollableHeight } = useWrapperContext(); const { getScrollElementScrollableHeight } = useWrapperContext();
const { const {
activeKey, activeKey,
@@ -111,9 +112,9 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
useEffect(() => { useEffect(() => {
if (open) { if (open) {
fetchData({}); fetchSSHData({});
} }
}, [open]); }, [open, action]);
const segmentOptions = useMemo( const segmentOptions = useMemo(
() => [ () => [
@@ -223,7 +224,13 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
}, [action, currentData, form, open, namespace]); }, [action, currentData, form, open, namespace]);
const handleFinish = async (values: InstanceFormValues) => { const handleFinish = async (values: InstanceFormValues) => {
await onFinish(values); await onFinish({
...values,
spec: {
...values.spec,
sshPublicKey: { name: sshKeyData?.name }
}
});
}; };
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -272,7 +279,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
accelerator: '1' accelerator: '1'
}, },
sshPublicKey: { sshPublicKey: {
name: 'gpustack-organization-ssh-public-key' name: 'gpustack-ssh-public-key'
}, },
volume: { volume: {
ephemeral: { ephemeral: {
@@ -286,7 +293,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
enable_ssh: false enable_ssh: false
}} }}
> >
<Basic /> <Basic action={action} />
<CollapsePanel <CollapsePanel
activeKey={collapseKeys} activeKey={collapseKeys}
accordion={false} accordion={false}
@@ -309,6 +316,7 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
children: ( children: (
<TemplateBasicForm <TemplateBasicForm
page="instance" page="instance"
disabled={action === PageAction.EDIT}
onceMaxRequest={onceMaxRequest} onceMaxRequest={onceMaxRequest}
/> />
) )
@@ -319,7 +327,12 @@ const GPUServiceInstanceForm: React.FC<InstanceFormProps> = forwardRef(
id: 'gpuservice.instance.section.storage' id: 'gpuservice.instance.section.storage'
}), }),
forceRender: true, forceRender: true,
children: <StorageVolume /> children: (
<StorageVolume
disabled={action === PageAction.EDIT}
action={action}
/>
)
} }
]} ]}
/> />
@@ -1,5 +1,6 @@
import { currentClusterAtom } from '@/atoms/gpuservice'; import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user'; import { getCurrentOrgNamespace } from '@/atoms/user';
import { PageAction } from '@/config';
import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui'; import { InputNumber as CInputNumber, Select } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Button, Flex, Form, Radio } from 'antd'; import { Button, Flex, Form, Radio } from 'antd';
@@ -19,7 +20,13 @@ const FieldBlock = styled.div`
const DEFAULT_TEMP_CAPACITY_GB = 50; const DEFAULT_TEMP_CAPACITY_GB = 50;
const StorageVolume = () => { const StorageVolume = ({
disabled,
action
}: {
disabled?: boolean;
action: PageActionType;
}) => {
const intl = useIntl(); const intl = useIntl();
const { fetchData: createStorage } = useCreateStorage(); const { fetchData: createStorage } = useCreateStorage();
const { detailData: storageData, fetchData: fetchStorage } = const { detailData: storageData, fetchData: fetchStorage } =
@@ -82,6 +89,7 @@ const StorageVolume = () => {
}} }}
> >
<Radio.Group <Radio.Group
disabled={disabled}
style={{ marginBottom: 12 }} style={{ marginBottom: 12 }}
value={storageMode} value={storageMode}
onChange={(e) => handleModeChange(e.target.value)} onChange={(e) => handleModeChange(e.target.value)}
@@ -98,6 +106,7 @@ const StorageVolume = () => {
} }
]} ]}
/> />
{action === PageAction.CREATE && (
<Button <Button
type="link" type="link"
size="small" size="small"
@@ -106,6 +115,7 @@ const StorageVolume = () => {
> >
{intl.formatMessage({ id: 'gpuservice.storage.add' })} {intl.formatMessage({ id: 'gpuservice.storage.add' })}
</Button> </Button>
)}
</Flex> </Flex>
{storageMode === StorageModeValueMap.Existing && ( {storageMode === StorageModeValueMap.Existing && (
@@ -121,6 +131,7 @@ const StorageVolume = () => {
]} ]}
> >
<Select <Select
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.storage.persistentVolume' id: 'gpuservice.storage.persistentVolume'
})} })}
@@ -149,6 +160,7 @@ const StorageVolume = () => {
]} ]}
> >
<CInputNumber <CInputNumber
disabled={disabled}
min={1} min={1}
precision={0} precision={0}
label={intl.formatMessage({ label={intl.formatMessage({
@@ -9,13 +9,27 @@ import { useIntl } from '@umijs/max';
import { Button, Flex } from 'antd'; import { Button, Flex } from 'antd';
import type { ColumnsType } from 'antd/lib/table'; import type { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { InstanceStatusLabelMap, rowActionList, status } from '../config'; import { InstanceStatusLabelMap, rowActionList, status } from '../config';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
type ConnectEntry = type ConnectEntry =
| { type: 'ssh'; key: string; command: string } | {
| { type: 'http'; key: string; port: number; url: string }; type: 'ssh';
key: string;
command: string;
name: string;
protocol: string;
}
| {
type: 'http';
key: string;
port: number;
url: string;
name: string;
protocol: string;
};
const getConnectEntries = (record: ListItem): ConnectEntry[] => { const getConnectEntries = (record: ListItem): ConnectEntry[] => {
const ip = record.status?.hostIPs?.[0]?.ip; const ip = record.status?.hostIPs?.[0]?.ip;
@@ -33,13 +47,17 @@ const getConnectEntries = (record: ListItem): ConnectEntry[] => {
return isSsh return isSsh
? { ? {
type: 'ssh', type: 'ssh',
name: 'SSH',
key: `ssh-${p.nodePort}`, key: `ssh-${p.nodePort}`,
protocol: _.toUpper(p.protocol),
command: `ssh root@${ip} -p ${p.nodePort}` command: `ssh root@${ip} -p ${p.nodePort}`
} }
: { : {
type: 'http', type: 'http',
name: p.name || 'HTTP',
key: `http-${p.nodePort}`, key: `http-${p.nodePort}`,
port: p.port, port: p.port,
protocol: _.toUpper(p.protocol),
url: `http://${ip}:${p.nodePort}` url: `http://${ip}:${p.nodePort}`
}; };
}); });
@@ -116,7 +134,7 @@ const useInstancesColumns = ({
className="text-tertiary" className="text-tertiary"
style={{ fontSize: 12, width: 34, flexShrink: 0 }} style={{ fontSize: 12, width: 34, flexShrink: 0 }}
> >
HTTP {entry.name || entry.protocol}
</span> </span>
<Button <Button
type="link" type="link"
@@ -0,0 +1,51 @@
import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user';
import { useAtomValue } from 'jotai';
import { useState } from 'react';
import { ListItem } from '../config/types';
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;
namespace: string;
clusterID?: number;
}>({
open: false,
name: '',
namespace: '',
clusterID: undefined
});
const openModal = (row?: ListItem) => {
const name = row?.metadata?.name || '';
const rowNamespace = row?.metadata?.namespace || namespace;
setOpenModalStatus({
open: true,
name,
namespace: rowNamespace,
clusterID
});
};
const closeModal = () => {
setOpenModalStatus({
open: false,
name: '',
namespace: '',
clusterID: undefined
});
};
return {
openViewEventsModalStatus: openModalStatus,
openViewEventsModal: openModal,
closeViewEventsModal: closeModal
};
};
export default useViewEvents;
@@ -2,7 +2,7 @@ import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrgNamespace } from '@/atoms/user'; import { getCurrentOrgNamespace } from '@/atoms/user';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useState } from 'react'; import { useState } from 'react';
import { GPU_SERVICE_INSTANCES_API } from '../apis'; import { GPU_SERVICE_INSTANCES_LOG_API } from '../apis';
import { ListItem } from '../config/types'; import { ListItem } from '../config/types';
const useViewLogs = () => { const useViewLogs = () => {
@@ -29,10 +29,11 @@ const useViewLogs = () => {
open: true, open: true,
url: url:
name && clusterID name && clusterID
? `${GPU_SERVICE_INSTANCES_API({ ? `${GPU_SERVICE_INSTANCES_LOG_API({
namespace: rowNamespace, namespace: rowNamespace,
clusterID clusterID,
})}/${name}/log` name
})}`
: '', : '',
tail: 1000, tail: 1000,
status: row?.status?.phase || undefined status: row?.status?.phase || undefined
+54 -12
View File
@@ -15,7 +15,7 @@ import {
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { ConfigProvider, Divider, Flex, message, Table } from 'antd'; import { ConfigProvider, Divider, Flex, message, Modal, Table } from 'antd';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import _ from 'lodash'; import _ from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -26,15 +26,18 @@ import {
queryGPUServiceInstances queryGPUServiceInstances
} from './apis'; } from './apis';
import AddModal from './components/add-modal'; import AddModal from './components/add-modal';
import ViewEventsModal from './components/view-events-modal';
import ViewLogsModal from './components/view-logs-modal'; import ViewLogsModal from './components/view-logs-modal';
import { FormData, ListItem } from './config/types'; import { FormData, ListItem } from './config/types';
import useInstancesColumns from './hooks/use-instances-columns'; import useInstancesColumns from './hooks/use-instances-columns';
import useViewEvents from './hooks/use-view-events';
import useViewLogs from './hooks/use-view-logs'; import useViewLogs from './hooks/use-view-logs';
import useCreateInstance from './services/use-create-instance'; import useCreateInstance from './services/use-create-instance';
import useUpdateInstance from './services/use-update-instance'; import useUpdateInstance from './services/use-update-instance';
const GPUService: React.FC = () => { const GPUService: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const [modal, modalContextHolder] = Modal.useModal();
const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom); const [currentCluster, setCurrentCluster] = useAtom(currentClusterAtom);
const clusterID = currentCluster?.id; const clusterID = currentCluster?.id;
// In admin "All" view there's no Org context, so the helper falls // In admin "All" view there's no Org context, so the helper falls
@@ -67,10 +70,10 @@ const GPUService: React.FC = () => {
{ ...params, namespace, clusterID: effectiveClusterID }, { ...params, namespace, clusterID: effectiveClusterID },
options options
); );
const total = res.items?.length ?? 0; const total = res?.items?.length ?? 0;
const perPage = params.perPage || 10; const perPage = params.perPage || 10;
return { return {
items: res.items ?? [], items: res?.items ?? [],
pagination: { pagination: {
total, total,
totalPage: Math.ceil(total / perPage), totalPage: Math.ceil(total / perPage),
@@ -109,6 +112,11 @@ const GPUService: React.FC = () => {
const { fetchData: updateInstance } = useUpdateInstance(); const { fetchData: updateInstance } = useUpdateInstance();
const { openViewLogsModal, closeViewLogsModal, openViewLogsModalStatus } = const { openViewLogsModal, closeViewLogsModal, openViewLogsModalStatus } =
useViewLogs(); useViewLogs();
const {
openViewEventsModal,
closeViewEventsModal,
openViewEventsModalStatus
} = useViewEvents();
const { const {
fetchClusterList, fetchClusterList,
cancelRequest: cancelClusterRequest, cancelRequest: cancelClusterRequest,
@@ -210,6 +218,36 @@ const GPUService: React.FC = () => {
} }
}; };
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 handleSelect = useMemoizedFn((val: string, row: ListItem) => { const handleSelect = useMemoizedFn((val: string, row: ListItem) => {
if (val === 'edit') { if (val === 'edit') {
handleEditInstance(row); handleEditInstance(row);
@@ -219,8 +257,12 @@ const GPUService: React.FC = () => {
name: row.metadata?.name, name: row.metadata?.name,
id: row.metadata?.name as any id: row.metadata?.name as any
}); });
} else if (val === 'recreate') {
handleRecreate(row);
} else if (val === 'viewlog') { } else if (val === 'viewlog') {
openViewLogsModal(row); openViewLogsModal(row);
} else if (val === 'viewevent') {
openViewEventsModal(row);
} }
}); });
@@ -316,15 +358,7 @@ const GPUService: React.FC = () => {
showSorterTooltip={false} showSorterTooltip={false}
rowKey={(record) => record.metadata.name} rowKey={(record) => record.metadata.name}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={false}
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/> />
</ConfigProvider> </ConfigProvider>
<AddModal <AddModal
@@ -341,7 +375,15 @@ const GPUService: React.FC = () => {
tail={openViewLogsModalStatus.tail} tail={openViewLogsModalStatus.tail}
onCancel={closeViewLogsModal} onCancel={closeViewLogsModal}
/> />
<ViewEventsModal
open={openViewEventsModalStatus.open}
name={openViewEventsModalStatus.name}
namespace={openViewEventsModalStatus.namespace}
clusterID={openViewEventsModalStatus.clusterID}
onCancel={closeViewEventsModal}
/>
<DeleteModal ref={modalRef} /> <DeleteModal ref={modalRef} />
{modalContextHolder}
</PageContainerInner> </PageContainerInner>
); );
}; };
@@ -1,5 +1,5 @@
import { currentClusterAtom } from '@/atoms/gpuservice'; import { currentClusterAtom } from '@/atoms/gpuservice';
import { getCurrentOrganizationId } from '@/atoms/user'; import { getCurrentOrgNamespace } from '@/atoms/user';
import { useQueryData } from '@gpustack/core-ui'; import { useQueryData } from '@gpustack/core-ui';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -11,8 +11,8 @@ interface QueryInstanceLogParams extends InstanceLogQueryParams {
} }
export default function useQueryInstanceLog() { export default function useQueryInstanceLog() {
const namespace = getCurrentOrganizationId();
const currentCluster = useAtomValue(currentClusterAtom); const currentCluster = useAtomValue(currentClusterAtom);
const namespace = getCurrentOrgNamespace(currentCluster?.owner_principal_id);
const clusterID = currentCluster?.id; const clusterID = currentCluster?.id;
const fetchDetail = useCallback( const fetchDetail = useCallback(
@@ -98,12 +98,12 @@ export const ReClaimPolicyOptions = [
]; ];
export const rowActionList = [ export const rowActionList = [
{ // {
label: 'common.button.edit', // label: 'common.button.edit',
key: 'edit', // key: 'edit',
locale: true, // locale: true,
icon: icons.EditOutlined // icon: icons.EditOutlined
}, // },
{ {
label: 'common.button.delete', label: 'common.button.delete',
key: 'delete', key: 'delete',
@@ -44,6 +44,7 @@ export interface ListItem {
accessMode: AccessModeType; accessMode: AccessModeType;
}; };
status: { status: {
phaseMessage?: string;
phase: 'Available' | 'Unavailable' | 'Pending'; phase: 'Available' | 'Unavailable' | 'Pending';
volume: { volume: {
name: string; name: string;
@@ -1,4 +1,4 @@
import { validateLabelNameRegxFor63 } from '@/config'; import { PageAction, validateLabelNameRegxFor63 } from '@/config';
import { import {
Input as CInput, Input as CInput,
InputNumber, InputNumber,
@@ -11,7 +11,7 @@ import { useEffect } from 'react';
import { FormData } from '../config/types'; import { FormData } from '../config/types';
import useQueryStorageClass from '../services/use-query-storage-class'; import useQueryStorageClass from '../services/use-query-storage-class';
const Basic = ({ open }: { open: boolean }) => { const Basic = ({ open, action }: { open: boolean; action: string }) => {
const intl = useIntl(); const intl = useIntl();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const { storageClassList, fetchData, loading } = useQueryStorageClass(); const { storageClassList, fetchData, loading } = useQueryStorageClass();
@@ -38,6 +38,7 @@ const Basic = ({ open }: { open: boolean }) => {
]} ]}
> >
<CInput.Input <CInput.Input
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'common.table.name' })} label={intl.formatMessage({ id: 'common.table.name' })}
required required
/> />
@@ -54,6 +55,7 @@ const Basic = ({ open }: { open: boolean }) => {
]} ]}
> >
<SealSelect <SealSelect
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storage.type' })} label={intl.formatMessage({ id: 'gpuservice.storage.type' })}
required required
loading={loading} loading={loading}
@@ -76,6 +78,7 @@ const Basic = ({ open }: { open: boolean }) => {
]} ]}
> >
<InputNumber <InputNumber
disabled={action === PageAction.EDIT}
label={intl.formatMessage({ id: 'gpuservice.storage.capacity' })} label={intl.formatMessage({ id: 'gpuservice.storage.capacity' })}
required required
/> />
@@ -62,7 +62,7 @@ const GPUServiceStorageForm: React.FC<StorageFormProps> = forwardRef(
preserve={false} preserve={false}
initialValues={{}} initialValues={{}}
> >
<Basic open={open} /> <Basic open={open} action={action} />
</Form> </Form>
); );
} }
@@ -59,11 +59,12 @@ const useStorageColumns = ({
dataIndex: ['status', 'phase'], dataIndex: ['status', 'phase'],
key: 'status', key: 'status',
sorter: false, sorter: false,
render: (value: string) => ( render: (value: string, record: ListItem) => (
<StatusTag <StatusTag
statusValue={{ statusValue={{
status: status[value], status: status[value],
text: StoragePhaseLabelMap[value] || value text: StoragePhaseLabelMap[value] || value,
message: record.status?.phaseMessage || ''
}} }}
></StatusTag> ></StatusTag>
) )
+3 -11
View File
@@ -66,10 +66,10 @@ const GPUServiceStorage: React.FC = () => {
{ ...params, namespace, clusterID: effectiveClusterID }, { ...params, namespace, clusterID: effectiveClusterID },
options options
); );
const total = res.items?.length ?? 0; const total = res?.items?.length ?? 0;
const perPage = params.perPage || 10; const perPage = params.perPage || 10;
return { return {
items: res.items ?? [], items: res?.items ?? [],
pagination: { pagination: {
total, total,
totalPage: Math.ceil(total / perPage), totalPage: Math.ceil(total / perPage),
@@ -311,15 +311,7 @@ const GPUServiceStorage: React.FC = () => {
showSorterTooltip={false} showSorterTooltip={false}
rowKey={(record) => record.metadata?.name as any} rowKey={(record) => record.metadata?.name as any}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={false}
size: 'middle',
showSizeChanger: true,
pageSize: queryParams.perPage,
current: queryParams.page,
total: dataSource.total,
hideOnSinglePage: queryParams.perPage === 10,
onChange: handlePageChange
}}
/> />
</ConfigProvider> </ConfigProvider>
</PageContainerInner> </PageContainerInner>
@@ -1,3 +1,4 @@
import { validateLabelNameRegxFor63 } from '@/config';
import { import {
Input as CInput, Input as CInput,
InputNumber, InputNumber,
@@ -26,10 +27,15 @@ export interface BasicResourceMax {
interface BasicProps { interface BasicProps {
page?: 'template' | 'instance'; page?: 'template' | 'instance';
disabled?: boolean;
onceMaxRequest?: BasicResourceMax; onceMaxRequest?: BasicResourceMax;
} }
const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => { const Basic: React.FC<BasicProps> = ({
page = 'template',
onceMaxRequest,
disabled
}) => {
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const intl = useIntl(); const intl = useIntl();
@@ -68,10 +74,17 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
{ {
required: true, required: true,
message: getRuleMessage('input', 'common.table.name') message: getRuleMessage('input', 'common.table.name')
},
{
pattern: validateLabelNameRegxFor63,
message: intl.formatMessage({
id: 'gpuservice.form.rule.name'
})
} }
]} ]}
> >
<CInput.Input <CInput.Input
disabled={disabled}
label={intl.formatMessage({ id: 'common.table.name' })} label={intl.formatMessage({ id: 'common.table.name' })}
required required
/> />
@@ -105,6 +118,7 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
]} ]}
> >
<SealSelect <SealSelect
disabled={disabled}
label={intl.formatMessage({ id: 'resources.table.vendor' })} label={intl.formatMessage({ id: 'resources.table.vendor' })}
required required
options={[...manufacturerOptions, { label: 'CPU', value: 'cpu' }]} options={[...manufacturerOptions, { label: 'CPU', value: 'cpu' }]}
@@ -123,12 +137,14 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
]} ]}
> >
<CInput.Input <CInput.Input
disabled={disabled}
label={intl.formatMessage({ id: 'gpuservice.template.image' })} label={intl.formatMessage({ id: 'gpuservice.template.image' })}
required required
/> />
</Form.Item> </Form.Item>
<Form.Item<FormData> name={['spec', 'imagePullPolicy']}> <Form.Item<FormData> name={['spec', 'imagePullPolicy']}>
<SealSelect <SealSelect
disabled={disabled}
label={intl.formatMessage({ label={intl.formatMessage({
id: 'gpuservice.template.imagePullPolicy' id: 'gpuservice.template.imagePullPolicy'
})} })}
@@ -144,6 +160,7 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
getValueProps={(value) => ({ value: stringifyCommand(value) })} getValueProps={(value) => ({ value: stringifyCommand(value) })}
> >
<Textarea <Textarea
disabled={disabled}
label={intl.formatMessage({ id: 'gpuservice.template.command' })} label={intl.formatMessage({ id: 'gpuservice.template.command' })}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: 'gpuservice.template.command.placeholder' id: 'gpuservice.template.command.placeholder'
@@ -164,6 +181,7 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: 'clusters.volume.mountPath.format' id: 'clusters.volume.mountPath.format'
})} })}
disabled={disabled}
/> />
</Form.Item> </Form.Item>
</div> </div>
@@ -181,6 +199,7 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
onceMaxRequest?.localStorage onceMaxRequest?.localStorage
)} )}
max={onceMaxRequest?.localStorage ?? undefined} max={onceMaxRequest?.localStorage ?? undefined}
disabled={disabled}
/> />
</Form.Item> </Form.Item>
</div> </div>
@@ -195,6 +214,7 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
<InputNumber <InputNumber
label={renderMaxLabel('CPU', onceMaxRequest?.cpu)} label={renderMaxLabel('CPU', onceMaxRequest?.cpu)}
max={onceMaxRequest?.cpu ?? undefined} max={onceMaxRequest?.cpu ?? undefined}
disabled={disabled}
/> />
</Form.Item> </Form.Item>
</div> </div>
@@ -212,13 +232,14 @@ const Basic: React.FC<BasicProps> = ({ page = 'template', onceMaxRequest }) => {
onceMaxRequest?.memory onceMaxRequest?.memory
)} )}
max={onceMaxRequest?.memory ?? undefined} max={onceMaxRequest?.memory ?? undefined}
disabled={disabled}
/> />
</Form.Item> </Form.Item>
</div> </div>
</Flex> </Flex>
<Ports /> <Ports disabled={disabled} />
<Env /> <Env disabled={disabled} />
</> </>
); );
}; };
+10 -2
View File
@@ -9,12 +9,17 @@ interface EnvItemProps {
onChange: (item: EnvItemType) => void; onChange: (item: EnvItemType) => void;
} }
const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => { const EnvItem: React.FC<EnvItemProps & { disabled?: boolean }> = ({
item,
onChange,
disabled = false
}) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<div style={{ display: 'flex', gap: 12, width: '100%' }}> <div style={{ display: 'flex', gap: 12, width: '100%' }}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<CInput.Input <CInput.Input
disabled={disabled}
value={item.name} value={item.name}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: 'gpuservice.template.env.name' id: 'gpuservice.template.env.name'
@@ -30,6 +35,7 @@ const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<CInput.Input <CInput.Input
disabled={disabled}
value={item.value} value={item.value}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: 'gpuservice.template.env.value' id: 'gpuservice.template.env.value'
@@ -47,7 +53,7 @@ const EnvItem: React.FC<EnvItemProps> = ({ item, onChange }) => {
); );
}; };
const Env: React.FC = () => { const Env: React.FC<{ disabled?: boolean }> = ({ disabled = false }) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData>();
const env = Form.useWatch(['spec', 'env'], form) || []; const env = Form.useWatch(['spec', 'env'], form) || [];
@@ -103,6 +109,7 @@ const Env: React.FC = () => {
]} ]}
> >
<MetadataList <MetadataList
disabled={disabled}
dataList={env} dataList={env}
btnText={intl.formatMessage({ id: 'gpuservice.template.env.add' })} btnText={intl.formatMessage({ id: 'gpuservice.template.env.add' })}
label={intl.formatMessage({ id: 'gpuservice.template.env' })} label={intl.formatMessage({ id: 'gpuservice.template.env' })}
@@ -111,6 +118,7 @@ const Env: React.FC = () => {
> >
{(item, index) => ( {(item, index) => (
<EnvItem <EnvItem
disabled={disabled}
key={index} key={index}
index={index} index={index}
item={item} item={item}
+50 -21
View File
@@ -6,12 +6,15 @@ import {
} from '@gpustack/core-ui'; } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form } from 'antd'; import { Form } from 'antd';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FormData, PortItem as PortItemType } from '../config/types'; import { FormData, PortItem as PortItemType } from '../config/types';
type PortProtocol = PortItemType['protocol']; type PortProtocol = PortItemType['protocol'];
const PORT_NAME_MAX = 16; const PORT_NAME_MAX = 16;
const PORT_MIN = 1;
const PORT_MAX = 65535;
const protocolOptions = [ const protocolOptions = [
{ {
@@ -25,9 +28,9 @@ const protocolOptions = [
]; ];
const wellKnownTcpPortNames: Record<number, string> = { const wellKnownTcpPortNames: Record<number, string> = {
22: 'SSH', 22: 'ssh',
80: 'HTTP', 80: 'http',
443: 'HTTPS' 443: 'https'
}; };
const getAutoFilledName = ( const getAutoFilledName = (
@@ -57,6 +60,7 @@ const getAutoFilledName = (
}; };
interface PortItemProps { interface PortItemProps {
disabled?: boolean;
item: PortItemType; item: PortItemType;
index: number; index: number;
onChange: (item: PortItemType) => void; onChange: (item: PortItemType) => void;
@@ -68,10 +72,11 @@ interface PortNameInputProps {
placeholder: string; placeholder: string;
} }
const PortNameInput: React.FC<PortNameInputProps> = ({ const PortNameInput: React.FC<PortNameInputProps & { disabled?: boolean }> = ({
value, value,
onChange, onChange,
placeholder placeholder,
disabled = false
}) => { }) => {
const [localValue, setLocalValue] = useState<string>(value ?? ''); const [localValue, setLocalValue] = useState<string>(value ?? '');
const isComposingRef = useRef(false); const isComposingRef = useRef(false);
@@ -85,6 +90,7 @@ const PortNameInput: React.FC<PortNameInputProps> = ({
return ( return (
<CInput.Input <CInput.Input
showCount showCount
disabled={disabled}
maxLength={PORT_NAME_MAX} maxLength={PORT_NAME_MAX}
value={localValue} value={localValue}
placeholder={placeholder} placeholder={placeholder}
@@ -109,12 +115,13 @@ const PortNameInput: React.FC<PortNameInputProps> = ({
); );
}; };
const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => { const PortItem: React.FC<PortItemProps & { disabled?: boolean }> = ({ item, index, disabled = false, onChange }) => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<div style={{ display: 'flex', gap: 12, width: '100%' }}> <div style={{ display: 'flex', gap: 12, width: '100%' }}>
<div style={{ width: 120 }}> <div style={{ width: 120 }}>
<SealSelect <SealSelect
disabled={disabled}
value={item.protocol} value={item.protocol}
options={protocolOptions} options={protocolOptions}
onChange={(value) => { onChange={(value) => {
@@ -136,8 +143,9 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
</div> </div>
<div style={{ width: 120 }}> <div style={{ width: 120 }}>
<CInputNumber <CInputNumber
min={1} min={PORT_MIN}
max={65535} max={PORT_MAX}
disabled={disabled}
precision={0} precision={0}
value={item.port} value={item.port}
onChange={(value) => { onChange={(value) => {
@@ -160,6 +168,7 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<PortNameInput <PortNameInput
disabled={disabled}
value={item.name} value={item.name}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: 'gpuservice.template.ports.name' id: 'gpuservice.template.ports.name'
@@ -176,13 +185,15 @@ const PortItem: React.FC<PortItemProps> = ({ item, index, onChange }) => {
); );
}; };
const Ports: React.FC = () => { const Ports: React.FC<{ disabled?: boolean }> = ({ disabled = false }) => {
const intl = useIntl(); const intl = useIntl();
const form = Form.useFormInstance<FormData>(); const form = Form.useFormInstance<FormData>();
const ports = Form.useWatch(['spec', 'ports'], form) || []; const ports = Form.useWatch(['spec', 'ports'], form) || [];
const updatePorts = (list: PortItemType[]) => { const updatePorts = (list: PortItemType[]) => {
form.setFieldValue(['spec', 'ports'], list); form.setFieldValue(['spec', 'ports'], list);
// setFieldValue doesn't run validators — re-run them so users see errors live.
form.validateFields([['spec', 'ports']]).catch(() => {});
}; };
const handleAdd = () => { const handleAdd = () => {
@@ -213,27 +224,31 @@ const Ports: React.FC = () => {
name={['spec', 'ports']} name={['spec', 'ports']}
rules={[ rules={[
{ {
validator: async (_, value) => { validator: async (_rule, value) => {
if (!value?.length) { if (!value?.length) {
return Promise.resolve(); return Promise.resolve();
} }
const hasInvalidPort = value.some(
(item: PortItemType) => !item.protocol || !item.port const items = value as PortItemType[];
);
if (hasInvalidPort) { if (_.some(items, (item) => !item.protocol || !item.port)) {
return Promise.reject( return Promise.reject(
new Error( new Error(
intl.formatMessage({ intl.formatMessage(
id: 'gpuservice.template.ports.invalid' { id: 'common.validate.value' },
{
name: intl.formatMessage({
id: 'gpuservice.template.ports'
}) })
}
)
) )
); );
} }
const hasInvalidName = value.some(
(item: PortItemType) => const namedItems = items.filter((item) => !!item.name);
item.name && item.name.length > PORT_NAME_MAX
); if (_.some(namedItems, (item) => item.name!.length > PORT_NAME_MAX)) {
if (hasInvalidName) {
return Promise.reject( return Promise.reject(
new Error( new Error(
intl.formatMessage({ intl.formatMessage({
@@ -242,12 +257,25 @@ const Ports: React.FC = () => {
) )
); );
} }
const names = namedItems.map((item) => item.name);
if (_.uniq(names).length !== names.length) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'gpuservice.template.ports.name.duplicate'
})
)
);
}
return Promise.resolve(); return Promise.resolve();
} }
} }
]} ]}
> >
<MetadataList <MetadataList
disabled={disabled}
dataList={ports} dataList={ports}
btnText={intl.formatMessage({ id: 'gpuservice.template.ports.add' })} btnText={intl.formatMessage({ id: 'gpuservice.template.ports.add' })}
label={intl.formatMessage({ id: 'gpuservice.template.ports' })} label={intl.formatMessage({ id: 'gpuservice.template.ports' })}
@@ -256,6 +284,7 @@ const Ports: React.FC = () => {
> >
{(item, index) => ( {(item, index) => (
<PortItem <PortItem
disabled={disabled}
key={index} key={index}
index={index} index={index}
item={item} item={item}
+2 -2
View File
@@ -11,8 +11,8 @@ import { useIntl } from '@umijs/max';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { GPUsConfigs } from '../../resources/config/gpu-driver';
import PageBox from '../../_components/page-box'; import PageBox from '../../_components/page-box';
import { GPUsConfigs } from '../../resources/config/gpu-driver';
import { import {
createGPUServiceTemplate, createGPUServiceTemplate,
deleteGPUServiceTemplate, deleteGPUServiceTemplate,
@@ -55,7 +55,7 @@ const GPUServiceTemplates: React.FC = () => {
() => [ () => [
...Object.values(GPUsConfigs).map((item) => ({ ...Object.values(GPUsConfigs).map((item) => ({
label: item.label, label: item.label,
value: item.value value: item.gpuVendor as string
})), })),
{ label: 'CPU', value: 'cpu' } { label: 'CPU', value: 'cpu' }
], ],