feat: distinguish same-name GPU service resources across users
Resource names are unique per owning principal, so different users can legally create same-name GPU instances, templates, SSH public keys, storage volumes and storage types — but the admin's cross-tenant lists and the instance-create template picker rendered them indistinguishably. - Group the template picker by owning scope: Your Templates, Global Templates, then — admin cross-tenant view only — one group per owner. A single group renders flat without headers. A plugin can take over grouping via hooks.useTemplateOwnerGroups when its principal model scopes templates beyond USER owners; a plugin without the hook keeps the flat list. - Add an admin-only Creator column to the Instances, SSH Public Keys, Storage and Storage Types tables, sitting right before Created (mirroring the API Keys page convention). Resolves creator_id to a username via the user directory, falling back to owner_principal_id for legacy rows — a personal-scope row's creator IS its owner. - Tag template cards with their owner scope (Global / username) for admins. The Creator column and the card tag defer to a plugin that registers the page's list columns / the OwnerScopeTag slot. Requires the gpustack server change that records creator_id on templates, SSH public keys and storage types.
This commit is contained in:
@@ -174,5 +174,9 @@ export default {
|
|||||||
'Please enter the temporary storage capacity',
|
'Please enter the temporary storage capacity',
|
||||||
'gpuservice.form.rule.name':
|
'gpuservice.form.rule.name':
|
||||||
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
|
"Lowercase letters, numbers, and '-'. Start and end with a letter or number, no consecutive '-', max 63 characters.",
|
||||||
'gpuservice.form.storage.select': 'Select Storage'
|
'gpuservice.form.storage.select': 'Select Storage',
|
||||||
|
'gpuservice.creator': 'Creator',
|
||||||
|
'gpuservice.owner.global': 'Global',
|
||||||
|
'gpuservice.template.group.yours': 'Your Templates',
|
||||||
|
'gpuservice.template.group.global': 'Global Templates'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -174,5 +174,9 @@ export default {
|
|||||||
'Data is cleared when the instance stops.',
|
'Data is cleared when the instance stops.',
|
||||||
'gpuservice.storage.persistentVolume.tips':
|
'gpuservice.storage.persistentVolume.tips':
|
||||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||||
'gpuservice.form.storage.select': 'ストレージを選択'
|
'gpuservice.form.storage.select': 'ストレージを選択',
|
||||||
|
'gpuservice.creator': '作成者',
|
||||||
|
'gpuservice.owner.global': 'グローバル',
|
||||||
|
'gpuservice.template.group.yours': '自分のテンプレート',
|
||||||
|
'gpuservice.template.group.global': 'グローバルテンプレート'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -171,5 +171,9 @@ export default {
|
|||||||
'Data is cleared when the instance stops.',
|
'Data is cleared when the instance stops.',
|
||||||
'gpuservice.storage.persistentVolume.tips':
|
'gpuservice.storage.persistentVolume.tips':
|
||||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||||
'gpuservice.form.storage.select': 'Выберите хранилище'
|
'gpuservice.form.storage.select': 'Выберите хранилище',
|
||||||
|
'gpuservice.creator': 'Создатель',
|
||||||
|
'gpuservice.owner.global': 'Глобальный',
|
||||||
|
'gpuservice.template.group.yours': 'Ваши шаблоны',
|
||||||
|
'gpuservice.template.group.global': 'Глобальные шаблоны'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -169,5 +169,9 @@ export default {
|
|||||||
'Data is cleared when the instance stops.',
|
'Data is cleared when the instance stops.',
|
||||||
'gpuservice.storage.persistentVolume.tips':
|
'gpuservice.storage.persistentVolume.tips':
|
||||||
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
'Data persists across instance restarts. Persistent volumes remain intact after instance termination and can be shared by multiple instances.',
|
||||||
'gpuservice.form.storage.select': 'Depolamayı Seç'
|
'gpuservice.form.storage.select': 'Depolamayı Seç',
|
||||||
|
'gpuservice.creator': 'Oluşturan',
|
||||||
|
'gpuservice.owner.global': 'Genel',
|
||||||
|
'gpuservice.template.group.yours': 'Şablonlarınız',
|
||||||
|
'gpuservice.template.group.global': 'Genel Şablonlar'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -158,5 +158,9 @@ export default {
|
|||||||
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
|
'gpuservice.storage.tempCapacity.required': '请输入临时存储容量',
|
||||||
'gpuservice.form.rule.name':
|
'gpuservice.form.rule.name':
|
||||||
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
|
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 63 个字符。',
|
||||||
'gpuservice.form.storage.select': '选择存储'
|
'gpuservice.form.storage.select': '选择存储',
|
||||||
|
'gpuservice.creator': '创建者',
|
||||||
|
'gpuservice.owner.global': '全局',
|
||||||
|
'gpuservice.template.group.yours': '我的模板',
|
||||||
|
'gpuservice.template.group.global': '全局模板'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { getGPUStackPlugin } from '@/plugins';
|
||||||
|
import { ThemeTag } from '@gpustack/core-ui';
|
||||||
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
|
import useUserDirectory from '../hooks/use-user-directory';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner tag for template cards, disambiguating same-name templates in
|
||||||
|
* the admin's cross-tenant view. Renders nothing for non-admin callers
|
||||||
|
* (the management page is `mine`-scoped for them) and when a plugin
|
||||||
|
* provides its own `OwnerScopeTag` slot.
|
||||||
|
*/
|
||||||
|
const OwnerTag: React.FC<{ ownerId?: number | null }> = ({ ownerId }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const access = useAccess();
|
||||||
|
const pluginOwnsTag = !!getGPUStackPlugin()?.components?.OwnerScopeTag;
|
||||||
|
const show = !!access.canSeeAdmin && !pluginOwnsTag;
|
||||||
|
const users = useUserDirectory(show);
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ownerId == null) {
|
||||||
|
return (
|
||||||
|
<ThemeTag color="gold" style={{ fontWeight: 400 }}>
|
||||||
|
{intl.formatMessage({ id: 'gpuservice.owner.global' })}
|
||||||
|
</ThemeTag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = users.get(ownerId);
|
||||||
|
if (!username) {
|
||||||
|
// Directory still loading, or a non-USER principal (built-in
|
||||||
|
// platform principal) — nothing meaningful to show on a card.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeTag color="blue" style={{ fontWeight: 400 }}>
|
||||||
|
{username}
|
||||||
|
</ThemeTag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OwnerTag;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { getGPUStackPlugin } from '@/plugins';
|
||||||
|
import { AutoTooltip } from '@gpustack/core-ui';
|
||||||
|
import { useAccess, useIntl } from '@umijs/max';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import useUserDirectory from './use-user-directory';
|
||||||
|
|
||||||
|
type CreatorAware = {
|
||||||
|
creator_id?: number | null;
|
||||||
|
owner_principal_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Concrete column shape rather than antd's `ColumnsType` union:
|
||||||
|
// spreading a union-typed array into a page's column literal breaks
|
||||||
|
// contextual typing for the sibling inline columns (their render
|
||||||
|
// params degrade to implicit `any`), while a concrete object type
|
||||||
|
// composes cleanly — same contract the plugin columns use.
|
||||||
|
type CreatorColumn<T> = {
|
||||||
|
title: string;
|
||||||
|
key: string;
|
||||||
|
ellipsis: { showTitle: false };
|
||||||
|
render: (text: any, record: T) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatorLabel: React.FC<{
|
||||||
|
record: CreatorAware;
|
||||||
|
users: Map<number, string>;
|
||||||
|
}> = ({ record, users }) => {
|
||||||
|
// Legacy rows predate `creator_id`. A personal-scope row's creator
|
||||||
|
// IS its owner, so the owner id doubles as the fallback — the same
|
||||||
|
// derivation the backend migration backfills with; keeping it here
|
||||||
|
// covers servers that haven't run the migration yet.
|
||||||
|
const id = record.creator_id ?? record.owner_principal_id;
|
||||||
|
const username = id != null ? users.get(id) : undefined;
|
||||||
|
if (username) {
|
||||||
|
return (
|
||||||
|
<AutoTooltip ghost maxWidth={240}>
|
||||||
|
{username}
|
||||||
|
</AutoTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Unresolvable: the directory is still loading, or a legacy row
|
||||||
|
// whose only attribution is a non-USER principal (platform-owned).
|
||||||
|
return <span>-</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Creator" column for the GPU-service list pages, attributing each
|
||||||
|
* row to the user who created it — disambiguates same-name rows from
|
||||||
|
* different users in the admin's cross-tenant view, and reads as a
|
||||||
|
* plain username for admin-created rows too. Returns `[]` for
|
||||||
|
* non-admin callers (their lists only ever contain their own rows)
|
||||||
|
* and when a plugin registers list columns for `pageKey` — the
|
||||||
|
* plugin's own attribution column covers the same ground.
|
||||||
|
*/
|
||||||
|
const useCreatorColumn = <T extends CreatorAware>(
|
||||||
|
pageKey: string
|
||||||
|
): CreatorColumn<T>[] => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const access = useAccess();
|
||||||
|
const pluginOwnsSlot = !!(
|
||||||
|
getGPUStackPlugin()?.listExtraColumns as Record<string, unknown> | undefined
|
||||||
|
)?.[pageKey];
|
||||||
|
const show = !!access.canSeeAdmin && !pluginOwnsSlot;
|
||||||
|
const users = useUserDirectory(show);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!show) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'gpuservice.creator' }),
|
||||||
|
key: 'creator',
|
||||||
|
ellipsis: { showTitle: false as const },
|
||||||
|
render: (_text: any, record: T) => (
|
||||||
|
<CreatorLabel record={record} users={users} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [show, users, intl]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCreatorColumn;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { queryUserDirectory } from '@/pages/users/apis';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
// Module-level cache: the directory is fetched once per app session and
|
||||||
|
// shared by every consumer (owner columns on the Instances / Public Keys /
|
||||||
|
// Storage tables, the template owner tags, the create-instance template
|
||||||
|
// picker). A failed fetch clears the cache so the next consumer retries.
|
||||||
|
let directoryPromise: Promise<Map<number, string>> | null = null;
|
||||||
|
|
||||||
|
const fetchDirectory = (): Promise<Map<number, string>> => {
|
||||||
|
directoryPromise ??= queryUserDirectory({ page: -1 })
|
||||||
|
.then(
|
||||||
|
(res) =>
|
||||||
|
new Map(
|
||||||
|
(res.items || [])
|
||||||
|
.filter((u) => u.id != null)
|
||||||
|
.map((u) => [u.id as number, u.username])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
directoryPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return directoryPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* id → username map of all users, for resolving `owner_principal_id`
|
||||||
|
* to a display name (a USER principal's id IS the user's id).
|
||||||
|
*
|
||||||
|
* Only fetches when `enabled` — callers gate on platform admin, since
|
||||||
|
* `/user-directory` 403s for regular users (who only ever see their
|
||||||
|
* own resources anyway). Returns an empty map until loaded.
|
||||||
|
*/
|
||||||
|
export default function useUserDirectory(enabled: boolean) {
|
||||||
|
const [users, setUsers] = useState<Map<number, string>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
let alive = true;
|
||||||
|
fetchDirectory()
|
||||||
|
.then((map) => {
|
||||||
|
if (alive) setUsers(map);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { PageAction } from '@/config';
|
|||||||
import { PageActionType } from '@/config/types';
|
import { PageActionType } from '@/config/types';
|
||||||
import useSubmitLock from '@/hooks/use-submit-lock';
|
import useSubmitLock from '@/hooks/use-submit-lock';
|
||||||
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
import { useQueryClusterList } from '@/pages/cluster-management/services/use-query-cluster-list';
|
||||||
|
import useUserDirectory from '@/pages/gpu-service/hooks/use-user-directory';
|
||||||
import Separator from '@/pages/llmodels/components/separator';
|
import Separator from '@/pages/llmodels/components/separator';
|
||||||
|
import { getGPUStackPlugin } from '@/plugins';
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
AlertBlockInfo,
|
AlertBlockInfo,
|
||||||
@@ -10,7 +12,7 @@ import {
|
|||||||
GSDrawer,
|
GSDrawer,
|
||||||
ModalFooter
|
ModalFooter
|
||||||
} from '@gpustack/core-ui';
|
} from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl, useModel } from '@umijs/max';
|
||||||
import { Empty, Input, Typography } from 'antd';
|
import { Empty, Input, Typography } from 'antd';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -18,7 +20,7 @@ import { ListItem as TemplateItem } from '../../templates/config/types';
|
|||||||
import useQueryTemplates from '../../templates/services/use-query-templates';
|
import useQueryTemplates from '../../templates/services/use-query-templates';
|
||||||
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
import { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||||
import GPUServiceInstanceForm from '../forms';
|
import GPUServiceInstanceForm from '../forms';
|
||||||
import TemplateSelector from '../forms/template-selector';
|
import TemplateSelector, { TemplateGroup } from '../forms/template-selector';
|
||||||
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
import useQueryInstanceTypes from '../services/use-query-instance-types';
|
||||||
import styles from '../styles/instances.module.less';
|
import styles from '../styles/instances.module.less';
|
||||||
import InstanceTypeList from './instance-type-list';
|
import InstanceTypeList from './instance-type-list';
|
||||||
@@ -79,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
realAction
|
realAction
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const { initialState } = useModel('@@initialState') || {};
|
||||||
|
const currentUser = initialState?.currentUser;
|
||||||
|
const pluginActive = !!getGPUStackPlugin();
|
||||||
|
const userDirectory = useUserDirectory(
|
||||||
|
!!currentUser?.is_admin && !pluginActive
|
||||||
|
);
|
||||||
const form = useRef<any>(null);
|
const form = useRef<any>(null);
|
||||||
const sessionRef = useRef(0);
|
const sessionRef = useRef(0);
|
||||||
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
|
const [instanceTypeSelection, setInstanceTypeSelection] = useState<{
|
||||||
@@ -395,6 +403,89 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Group the picker by owning scope so same-name templates stay
|
||||||
|
// distinguishable: the caller's own templates first, then the
|
||||||
|
// admin-curated Global presets, then — platform admin's cross-tenant
|
||||||
|
// view only — other users' templates, one group per owner.
|
||||||
|
//
|
||||||
|
// The default buckets below assume every non-Global owner is a USER
|
||||||
|
// principal. A plugin's principal model may scope templates to other
|
||||||
|
// owner kinds (no user-directory entry, not the caller's user id),
|
||||||
|
// which these buckets would mislabel — so a plugin can take over
|
||||||
|
// grouping via `hooks.useTemplateOwnerGroups`. The registry is wired
|
||||||
|
// at boot, so the conditional hook call is render-stable — same
|
||||||
|
// contract as `usePluginListColumns`' function entries.
|
||||||
|
const usePluginTemplateGroups = getGPUStackPlugin()?.hooks
|
||||||
|
?.useTemplateOwnerGroups as
|
||||||
|
| ((items: TemplateItem[]) => TemplateGroup[])
|
||||||
|
| undefined;
|
||||||
|
const pluginTemplateGroups = usePluginTemplateGroups?.(filteredTemplates);
|
||||||
|
|
||||||
|
const templateGroups: TemplateGroup[] = useMemo(() => {
|
||||||
|
if (pluginTemplateGroups) {
|
||||||
|
return pluginTemplateGroups;
|
||||||
|
}
|
||||||
|
if (pluginActive) {
|
||||||
|
// Plugin present but without the grouping hook (older plugin
|
||||||
|
// build): keep the flat list rather than mislabeling owners
|
||||||
|
// outside the USER-principal model.
|
||||||
|
return filteredTemplates.length
|
||||||
|
? [{ key: 'all', label: null, items: filteredTemplates }]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
const yours: TemplateItem[] = [];
|
||||||
|
const globals: TemplateItem[] = [];
|
||||||
|
const byOwner = new Map<number, TemplateItem[]>();
|
||||||
|
|
||||||
|
filteredTemplates.forEach((item) => {
|
||||||
|
if (item.owner_principal_id == null) {
|
||||||
|
globals.push(item);
|
||||||
|
} else if (item.owner_principal_id === currentUser?.id) {
|
||||||
|
yours.push(item);
|
||||||
|
} else {
|
||||||
|
const list = byOwner.get(item.owner_principal_id) || [];
|
||||||
|
list.push(item);
|
||||||
|
byOwner.set(item.owner_principal_id, list);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups: TemplateGroup[] = [];
|
||||||
|
if (yours.length) {
|
||||||
|
groups.push({
|
||||||
|
key: 'yours',
|
||||||
|
label: intl.formatMessage({ id: 'gpuservice.template.group.yours' }),
|
||||||
|
items: yours
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (globals.length) {
|
||||||
|
groups.push({
|
||||||
|
key: 'global',
|
||||||
|
label: intl.formatMessage({ id: 'gpuservice.template.group.global' }),
|
||||||
|
items: globals
|
||||||
|
});
|
||||||
|
}
|
||||||
|
groups.push(
|
||||||
|
...[...byOwner.entries()]
|
||||||
|
.map(([ownerId, items]) => ({
|
||||||
|
key: `owner-${ownerId}`,
|
||||||
|
// `#id` is a placeholder for the moment before the user
|
||||||
|
// directory resolves (the memo recomputes once it lands)
|
||||||
|
// and for the API-only case of a non-USER owner.
|
||||||
|
label: userDirectory.get(ownerId) || `#${ownerId}`,
|
||||||
|
items
|
||||||
|
}))
|
||||||
|
.sort((a, b) => String(a.label).localeCompare(String(b.label)))
|
||||||
|
);
|
||||||
|
return groups;
|
||||||
|
}, [
|
||||||
|
filteredTemplates,
|
||||||
|
currentUser?.id,
|
||||||
|
userDirectory,
|
||||||
|
intl,
|
||||||
|
pluginActive,
|
||||||
|
pluginTemplateGroups
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
guard(() => form.current?.submit());
|
guard(() => form.current?.submit());
|
||||||
};
|
};
|
||||||
@@ -531,7 +622,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
|||||||
{filteredTemplates.length > 0 && initializedRef.current ? (
|
{filteredTemplates.length > 0 && initializedRef.current ? (
|
||||||
<TemplateSelector
|
<TemplateSelector
|
||||||
value={templateId}
|
value={templateId}
|
||||||
dataList={filteredTemplates}
|
groups={templateGroups}
|
||||||
onChange={handleTemplateChange}
|
onChange={handleTemplateChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export interface ListItem extends FormData {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
|
creator_id?: number | null;
|
||||||
status?: InstanceStatus | null;
|
status?: InstanceStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Fragment } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ const TemplateGrid = styled.div`
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const GroupTitle = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ant-color-text-tertiary);
|
||||||
|
margin-bottom: -8px;
|
||||||
|
`;
|
||||||
|
|
||||||
const TemplateContent = styled.div`
|
const TemplateContent = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -41,16 +49,22 @@ const TemplateContent = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export interface TemplateGroup {
|
||||||
|
key: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
items: TemplateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
interface TemplateSelectorProps {
|
interface TemplateSelectorProps {
|
||||||
value?: number;
|
value?: number;
|
||||||
onChange?: (value: number, item: TemplateItem) => void;
|
onChange?: (value: number, item: TemplateItem) => void;
|
||||||
dataList?: TemplateItem[];
|
groups?: TemplateGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
dataList = []
|
groups = []
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -59,48 +73,59 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
|||||||
onChange?.(item.id, item);
|
onChange?.(item.id, item);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderItem = (item: TemplateItem) => (
|
||||||
|
<TemplateCard
|
||||||
|
key={item.id}
|
||||||
|
clickable
|
||||||
|
ghost
|
||||||
|
hoverable
|
||||||
|
height={102}
|
||||||
|
active={value === item.id}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
<TemplateContent>
|
||||||
|
<div className="name">
|
||||||
|
<AutoTooltip ghost minWidth={20}>
|
||||||
|
{item.displayName || item.name || '-'}
|
||||||
|
</AutoTooltip>
|
||||||
|
</div>
|
||||||
|
<div className="info">
|
||||||
|
<span>
|
||||||
|
<IconFont className="icon" type="icon-model" />{' '}
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.template.image'
|
||||||
|
})}
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<AutoTooltip ghost minWidth={20}>
|
||||||
|
<span className="value">{item.spec?.image || '-'}</span>
|
||||||
|
</AutoTooltip>
|
||||||
|
</div>
|
||||||
|
<div className="info">
|
||||||
|
<span>
|
||||||
|
<IconFont className="icon" type="icon-storage-outlined" />{' '}
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'gpuservice.instance.template.mount'
|
||||||
|
})}
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<span className="value">{item.spec?.volumeMount || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</TemplateContent>
|
||||||
|
</TemplateCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
// A single group renders flat — the header would only restate what
|
||||||
|
// the whole list already is.
|
||||||
|
const showGroupTitles = groups.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TemplateGrid>
|
<TemplateGrid>
|
||||||
{dataList.map((item: TemplateItem) => (
|
{groups.map((group) => (
|
||||||
<TemplateCard
|
<Fragment key={group.key}>
|
||||||
key={item.id}
|
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
|
||||||
clickable
|
{group.items.map(renderItem)}
|
||||||
ghost
|
</Fragment>
|
||||||
hoverable
|
|
||||||
height={102}
|
|
||||||
active={value === item.id}
|
|
||||||
onClick={() => handleSelect(item)}
|
|
||||||
>
|
|
||||||
<TemplateContent>
|
|
||||||
<div className="name">
|
|
||||||
<AutoTooltip ghost minWidth={20}>
|
|
||||||
{item.displayName || item.name || '-'}
|
|
||||||
</AutoTooltip>
|
|
||||||
</div>
|
|
||||||
<div className="info">
|
|
||||||
<span>
|
|
||||||
<IconFont className="icon" type="icon-model" />{' '}
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: 'gpuservice.instance.template.image'
|
|
||||||
})}
|
|
||||||
:
|
|
||||||
</span>
|
|
||||||
<AutoTooltip ghost minWidth={20}>
|
|
||||||
<span className="value">{item.spec?.image || '-'}</span>
|
|
||||||
</AutoTooltip>
|
|
||||||
</div>
|
|
||||||
<div className="info">
|
|
||||||
<span>
|
|
||||||
<IconFont className="icon" type="icon-storage-outlined" />{' '}
|
|
||||||
{intl.formatMessage({
|
|
||||||
id: 'gpuservice.instance.template.mount'
|
|
||||||
})}
|
|
||||||
:
|
|
||||||
</span>
|
|
||||||
<span className="value">{item.spec?.volumeMount || '-'}</span>
|
|
||||||
</div>
|
|
||||||
</TemplateContent>
|
|
||||||
</TemplateCard>
|
|
||||||
))}
|
))}
|
||||||
</TemplateGrid>
|
</TemplateGrid>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||||
import { ExportOutlined } from '@ant-design/icons';
|
import { ExportOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -172,6 +173,7 @@ const useInstancesColumns = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const access = useAccess();
|
const access = useAccess();
|
||||||
const pluginCols = usePluginListColumns('gpuInstances');
|
const pluginCols = usePluginListColumns('gpuInstances');
|
||||||
|
const creatorCols = useCreatorColumn<ListItem>('gpuInstances');
|
||||||
|
|
||||||
const renderInstanceType = (record: ListItem) => {
|
const renderInstanceType = (record: ListItem) => {
|
||||||
const description =
|
const description =
|
||||||
@@ -403,6 +405,7 @@ const useInstancesColumns = ({
|
|||||||
</AutoTooltip>
|
</AutoTooltip>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
...creatorCols,
|
||||||
{
|
{
|
||||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
@@ -441,7 +444,8 @@ const useInstancesColumns = ({
|
|||||||
clusterList,
|
clusterList,
|
||||||
intl,
|
intl,
|
||||||
pvCapacityByName,
|
pvCapacityByName,
|
||||||
pluginCols
|
pluginCols,
|
||||||
|
creatorCols
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ListItem {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
owner_principal_id?: number | null;
|
owner_principal_id?: number | null;
|
||||||
|
creator_id?: number | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||||
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
|
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
@@ -35,6 +36,7 @@ const usePublicKeyColumns = ({
|
|||||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const pluginCols = usePluginListColumns('gpuPublicKeys');
|
const pluginCols = usePluginListColumns('gpuPublicKeys');
|
||||||
|
const creatorCols = useCreatorColumn<ListItem>('gpuPublicKeys');
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const pluginRendered = pluginCols.map((c) => ({
|
const pluginRendered = pluginCols.map((c) => ({
|
||||||
title: intl.formatMessage({ id: c.titleId }),
|
title: intl.formatMessage({ id: c.titleId }),
|
||||||
@@ -62,6 +64,7 @@ const usePublicKeyColumns = ({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
...pluginRendered,
|
...pluginRendered,
|
||||||
|
...creatorCols,
|
||||||
{
|
{
|
||||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
@@ -89,7 +92,7 @@ const usePublicKeyColumns = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [handleSelect, sortOrder, intl, pluginCols]);
|
}, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usePublicKeyColumns;
|
export default usePublicKeyColumns;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface ListItem {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
owner_principal_id?: number | null;
|
owner_principal_id?: number | null;
|
||||||
|
creator_id?: number | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||||
import { FolderOutlined } from '@ant-design/icons';
|
import { FolderOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +49,7 @@ const useStorageTypeColumns = ({
|
|||||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const pluginCols = usePluginListColumns('gpuStorageTypes');
|
const pluginCols = usePluginListColumns('gpuStorageTypes');
|
||||||
|
const creatorCols = useCreatorColumn<ListItem>('gpuStorageTypes');
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const pluginRendered = pluginCols.map((c) => ({
|
const pluginRendered = pluginCols.map((c) => ({
|
||||||
title: intl.formatMessage({ id: c.titleId }),
|
title: intl.formatMessage({ id: c.titleId }),
|
||||||
@@ -79,6 +81,7 @@ const useStorageTypeColumns = ({
|
|||||||
sorter: false,
|
sorter: false,
|
||||||
render: (_text, record) => getKindLabel(record)
|
render: (_text, record) => getKindLabel(record)
|
||||||
},
|
},
|
||||||
|
...creatorCols,
|
||||||
// {
|
// {
|
||||||
// title: intl.formatMessage({ id: 'common.table.description' }),
|
// title: intl.formatMessage({ id: 'common.table.description' }),
|
||||||
// dataIndex: 'description',
|
// dataIndex: 'description',
|
||||||
@@ -115,7 +118,7 @@ const useStorageTypeColumns = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [handleSelect, sortOrder, intl, pluginCols]);
|
}, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useStorageTypeColumns;
|
export default useStorageTypeColumns;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface ListItem {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
owner_principal_id?: number | null;
|
owner_principal_id?: number | null;
|
||||||
|
creator_id?: number | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||||
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
|
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
@@ -20,6 +21,7 @@ const useStorageColumns = ({
|
|||||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const pluginCols = usePluginListColumns('gpuStorage');
|
const pluginCols = usePluginListColumns('gpuStorage');
|
||||||
|
const creatorCols = useCreatorColumn<ListItem>('gpuStorage');
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const pluginRendered = pluginCols.map((c) => ({
|
const pluginRendered = pluginCols.map((c) => ({
|
||||||
title: intl.formatMessage({ id: c.titleId }),
|
title: intl.formatMessage({ id: c.titleId }),
|
||||||
@@ -68,6 +70,7 @@ const useStorageColumns = ({
|
|||||||
sorter: false,
|
sorter: false,
|
||||||
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
|
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
|
||||||
},
|
},
|
||||||
|
...creatorCols,
|
||||||
// {
|
// {
|
||||||
// title: intl.formatMessage({ id: 'common.table.status' }),
|
// title: intl.formatMessage({ id: 'common.table.status' }),
|
||||||
// dataIndex: ['status', 'phase'],
|
// dataIndex: ['status', 'phase'],
|
||||||
@@ -111,7 +114,14 @@ const useStorageColumns = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [handleSelect, sortOrder, storageClassList, intl, pluginCols]);
|
}, [
|
||||||
|
handleSelect,
|
||||||
|
sortOrder,
|
||||||
|
storageClassList,
|
||||||
|
intl,
|
||||||
|
pluginCols,
|
||||||
|
creatorCols
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useStorageColumns;
|
export default useStorageColumns;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
|
|||||||
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
|
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
|
||||||
import vllmLogo from '@/assets/logo/vllm.png';
|
import vllmLogo from '@/assets/logo/vllm.png';
|
||||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||||
|
import OwnerTag from '@/pages/gpu-service/components/owner-tag';
|
||||||
import {
|
import {
|
||||||
GPUsConfigs,
|
GPUsConfigs,
|
||||||
manfacturerValueMap
|
manfacturerValueMap
|
||||||
@@ -266,6 +267,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
|||||||
name="OwnerScopeTag"
|
name="OwnerScopeTag"
|
||||||
context={{ ownerPrincipalId: data.owner_principal_id }}
|
context={{ ownerPrincipalId: data.owner_principal_id }}
|
||||||
/>
|
/>
|
||||||
|
<OwnerTag ownerId={data.owner_principal_id} />
|
||||||
</CardName>
|
</CardName>
|
||||||
<InfoItem>
|
<InfoItem>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface ListItem extends FormData {
|
|||||||
// the `OwnerScopeTag` slot on the card so the owner is visible at a
|
// the `OwnerScopeTag` slot on the card so the owner is visible at a
|
||||||
// glance. Optional on the wire — absent in single-owner builds.
|
// glance. Optional on the wire — absent in single-owner builds.
|
||||||
owner_principal_id?: number | null;
|
owner_principal_id?: number | null;
|
||||||
|
creator_id?: number | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user