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',
|
||||
'gpuservice.form.rule.name':
|
||||
"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.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'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.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'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.',
|
||||
'gpuservice.storage.persistentVolume.tips':
|
||||
'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.form.rule.name':
|
||||
'由小写字母、数字和 "-" 组成,以字母或数字开头和结尾,不能包含连续的 "-",最多 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 useSubmitLock from '@/hooks/use-submit-lock';
|
||||
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 { getGPUStackPlugin } from '@/plugins';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AlertBlockInfo,
|
||||
@@ -10,7 +12,7 @@ import {
|
||||
GSDrawer,
|
||||
ModalFooter
|
||||
} from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Empty, Input, Typography } from 'antd';
|
||||
import _ from 'lodash';
|
||||
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 { FormData, InstanceTypeItem, ListItem } from '../config/types';
|
||||
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 styles from '../styles/instances.module.less';
|
||||
import InstanceTypeList from './instance-type-list';
|
||||
@@ -79,6 +81,12 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
realAction
|
||||
}) => {
|
||||
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 sessionRef = useRef(0);
|
||||
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 = () => {
|
||||
guard(() => form.current?.submit());
|
||||
};
|
||||
@@ -531,7 +622,7 @@ const AddModal: React.FC<AddModalProps> = ({
|
||||
{filteredTemplates.length > 0 && initializedRef.current ? (
|
||||
<TemplateSelector
|
||||
value={templateId}
|
||||
dataList={filteredTemplates}
|
||||
groups={templateGroups}
|
||||
onChange={handleTemplateChange}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface ListItem extends FormData {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
creator_id?: number | null;
|
||||
status?: InstanceStatus | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AutoTooltip, IconFont, TemplateCard } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Fragment } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { ListItem as TemplateItem } from '../../templates/config/types';
|
||||
|
||||
@@ -9,6 +10,13 @@ const TemplateGrid = styled.div`
|
||||
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`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -41,16 +49,22 @@ const TemplateContent = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export interface TemplateGroup {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
items: TemplateItem[];
|
||||
}
|
||||
|
||||
interface TemplateSelectorProps {
|
||||
value?: number;
|
||||
onChange?: (value: number, item: TemplateItem) => void;
|
||||
dataList?: TemplateItem[];
|
||||
groups?: TemplateGroup[];
|
||||
}
|
||||
|
||||
const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
dataList = []
|
||||
groups = []
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -59,48 +73,59 @@ const TemplateSelector: React.FC<TemplateSelectorProps> = ({
|
||||
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 (
|
||||
<TemplateGrid>
|
||||
{dataList.map((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>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
{showGroupTitles && <GroupTitle>{group.label}</GroupTitle>}
|
||||
{group.items.map(renderItem)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TemplateGrid>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
@@ -172,6 +173,7 @@ const useInstancesColumns = ({
|
||||
const intl = useIntl();
|
||||
const access = useAccess();
|
||||
const pluginCols = usePluginListColumns('gpuInstances');
|
||||
const creatorCols = useCreatorColumn<ListItem>('gpuInstances');
|
||||
|
||||
const renderInstanceType = (record: ListItem) => {
|
||||
const description =
|
||||
@@ -403,6 +405,7 @@ const useInstancesColumns = ({
|
||||
</AutoTooltip>
|
||||
)
|
||||
},
|
||||
...creatorCols,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
@@ -441,7 +444,8 @@ const useInstancesColumns = ({
|
||||
clusterList,
|
||||
intl,
|
||||
pvCapacityByName,
|
||||
pluginCols
|
||||
pluginCols,
|
||||
creatorCols
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ListItem {
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
owner_principal_id?: number | null;
|
||||
creator_id?: number | null;
|
||||
name?: string;
|
||||
displayName?: 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 { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
@@ -35,6 +36,7 @@ const usePublicKeyColumns = ({
|
||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||
const intl = useIntl();
|
||||
const pluginCols = usePluginListColumns('gpuPublicKeys');
|
||||
const creatorCols = useCreatorColumn<ListItem>('gpuPublicKeys');
|
||||
return useMemo(() => {
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
@@ -62,6 +64,7 @@ const usePublicKeyColumns = ({
|
||||
)
|
||||
},
|
||||
...pluginRendered,
|
||||
...creatorCols,
|
||||
{
|
||||
title: intl.formatMessage({ id: 'common.table.createTime' }),
|
||||
dataIndex: 'created_at',
|
||||
@@ -89,7 +92,7 @@ const usePublicKeyColumns = ({
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [handleSelect, sortOrder, intl, pluginCols]);
|
||||
}, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
|
||||
};
|
||||
|
||||
export default usePublicKeyColumns;
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ListItem {
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
owner_principal_id?: number | null;
|
||||
creator_id?: number | null;
|
||||
displayName?: string | null;
|
||||
description?: string | null;
|
||||
name: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { FolderOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ const useStorageTypeColumns = ({
|
||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||
const intl = useIntl();
|
||||
const pluginCols = usePluginListColumns('gpuStorageTypes');
|
||||
const creatorCols = useCreatorColumn<ListItem>('gpuStorageTypes');
|
||||
return useMemo(() => {
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
@@ -79,6 +81,7 @@ const useStorageTypeColumns = ({
|
||||
sorter: false,
|
||||
render: (_text, record) => getKindLabel(record)
|
||||
},
|
||||
...creatorCols,
|
||||
// {
|
||||
// title: intl.formatMessage({ id: 'common.table.description' }),
|
||||
// dataIndex: 'description',
|
||||
@@ -115,7 +118,7 @@ const useStorageTypeColumns = ({
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [handleSelect, sortOrder, intl, pluginCols]);
|
||||
}, [handleSelect, sortOrder, intl, pluginCols, creatorCols]);
|
||||
};
|
||||
|
||||
export default useStorageTypeColumns;
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ListItem {
|
||||
updated_at: string;
|
||||
deleted_at?: string | null;
|
||||
owner_principal_id?: number | null;
|
||||
creator_id?: number | null;
|
||||
displayName?: string | null;
|
||||
description?: string | null;
|
||||
name: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import useCreatorColumn from '@/pages/gpu-service/hooks/use-creator-column';
|
||||
import { usePluginListColumns } from '@/plugins/list-extra-columns';
|
||||
import { AutoTooltip, DropdownButtons } from '@gpustack/core-ui';
|
||||
import { useIntl } from '@umijs/max';
|
||||
@@ -20,6 +21,7 @@ const useStorageColumns = ({
|
||||
}: ColumnsHookProps): ColumnsType<ListItem> => {
|
||||
const intl = useIntl();
|
||||
const pluginCols = usePluginListColumns('gpuStorage');
|
||||
const creatorCols = useCreatorColumn<ListItem>('gpuStorage');
|
||||
return useMemo(() => {
|
||||
const pluginRendered = pluginCols.map((c) => ({
|
||||
title: intl.formatMessage({ id: c.titleId }),
|
||||
@@ -68,6 +70,7 @@ const useStorageColumns = ({
|
||||
sorter: false,
|
||||
render: (value: string) => (value ? value.replace(/Gi$/, 'GB') : '-')
|
||||
},
|
||||
...creatorCols,
|
||||
// {
|
||||
// title: intl.formatMessage({ id: 'common.table.status' }),
|
||||
// dataIndex: ['status', 'phase'],
|
||||
@@ -111,7 +114,14 @@ const useStorageColumns = ({
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [handleSelect, sortOrder, storageClassList, intl, pluginCols]);
|
||||
}, [
|
||||
handleSelect,
|
||||
sortOrder,
|
||||
storageClassList,
|
||||
intl,
|
||||
pluginCols,
|
||||
creatorCols
|
||||
]);
|
||||
};
|
||||
|
||||
export default useStorageColumns;
|
||||
|
||||
@@ -14,6 +14,7 @@ import tensorflowkLogo from '@/assets/logo/tensorflow.svg';
|
||||
import ubuntuLogo from '@/assets/logo/ubuntu_logo.png';
|
||||
import vllmLogo from '@/assets/logo/vllm.png';
|
||||
import PluginExtraFields from '@/components/plugin-extra-fields';
|
||||
import OwnerTag from '@/pages/gpu-service/components/owner-tag';
|
||||
import {
|
||||
GPUsConfigs,
|
||||
manfacturerValueMap
|
||||
@@ -266,6 +267,7 @@ const TemplateCardItem: React.FC<TemplateCardProps> = ({ data, onSelect }) => {
|
||||
name="OwnerScopeTag"
|
||||
context={{ ownerPrincipalId: data.owner_principal_id }}
|
||||
/>
|
||||
<OwnerTag ownerId={data.owner_principal_id} />
|
||||
</CardName>
|
||||
<InfoItem>
|
||||
<span>
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ListItem extends FormData {
|
||||
// the `OwnerScopeTag` slot on the card so the owner is visible at a
|
||||
// glance. Optional on the wire — absent in single-owner builds.
|
||||
owner_principal_id?: number | null;
|
||||
creator_id?: number | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user