feat: tease enterprise multi-tenancy and API key controls in OSS

Add an Organizations menu entry (admin-only) that routes to an upsell
page explaining the multi-tenancy module, and surface disabled
IP Access Control / Quota Limit items in the API Key dropdown with a
tooltip pointing at the enterprise edition. Both placeholders are
shadowed by the enterprise plugin at build time: the route merger
removes the OSS Organizations entry by name, and the dropdown skips
each placeholder when the same key is contributed via configActions.
This commit is contained in:
gitlawr
2026-06-05 16:41:34 +08:00
committed by jialin
parent 46f859cc3c
commit 42ac377c17
23 changed files with 246 additions and 10 deletions
+55 -5
View File
@@ -1,15 +1,27 @@
// columns.ts
import { tableSorter } from '@/config/settings';
import { AutoTooltip, DropdownButtons, icons } from '@gpustack/core-ui';
import { DashboardOutlined } from '@ant-design/icons';
import {
AutoTooltip,
DropdownButtons,
IconFont,
icons
} from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { MenuProps, Tag } from 'antd';
import { MenuProps, Tag, Tooltip } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import dayjs from 'dayjs';
import { useMemo } from 'react';
import { ListItem } from '../config/types';
import type { APIKeyConfigAction } from '../plugin';
type APIKeyAction = Global.ActionItem<ListItem> & {
type APIKeyAction = Omit<Global.ActionItem<ListItem>, 'disabled' | 'label'> & {
// Per-row callback (function) is the host's default; placeholders use a
// plain boolean to render the menu item grayed out unconditionally.
disabled?: boolean | ((record: ListItem) => boolean);
// ReactNode allowed so disabled placeholders can render a Tooltip-
// wrapped label (paired with `locale: false`).
label: string | React.ReactNode;
onClick?: (record: ListItem) => void;
};
@@ -68,10 +80,48 @@ const useModelsColumns = ({
onClick: (record: ListItem) => onConfigAction?.(a.key, record)
}));
return [...builtIns, ...fromPlugins].sort(
// Show disabled placeholders for IP Access Control / Quota Limit in
// the OSS build only — when the enterprise plugin contributes the
// real entry under the same key, skip the placeholder so the live
// action takes over. Keeps the dropdown's surface area consistent
// between editions while making the upgrade path discoverable.
const pluginKeys = new Set(configActions.map((a) => a.key));
const enterpriseTooltip = intl.formatMessage({
id: 'common.enterprise.feature'
});
const enterprisePlaceholder = (labelId: string): React.ReactNode => (
<Tooltip title={enterpriseTooltip} placement="left">
<span style={{ display: 'inline-block' }}>
{intl.formatMessage({ id: labelId })}
</span>
</Tooltip>
);
const placeholders: RankedAction[] = [];
if (!pluginKeys.has('ipConfig')) {
placeholders.push({
key: 'ipConfig',
label: enterprisePlaceholder('apikeys.button.ipConfig'),
locale: false,
icon: <IconFont type="icon-safe-ip" />,
disabled: true,
priority: 12
});
}
if (!pluginKeys.has('quotaLimit')) {
placeholders.push({
key: 'quotaLimit',
label: enterprisePlaceholder('quotaLimits.button.title'),
locale: false,
icon: <DashboardOutlined />,
disabled: true,
priority: 14
});
}
return [...builtIns, ...fromPlugins, ...placeholders].sort(
(a, b) => a.priority - b.priority
);
}, [configActions, onConfigAction]);
}, [intl, configActions, onConfigAction]);
return useMemo(() => {
return [
+80
View File
@@ -0,0 +1,80 @@
import { IconFont } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max';
import { Button, Result, Typography } from 'antd';
import PageBox from '../_components/page-box';
// OSS-only upsell page for the multi-tenancy module. The real
// Organizations CRUD UI ships in the enterprise plugin and shadows
// this route via `routes.extensions.ts` — when the enterprise plugin
// is loaded, the merger removes this entry by name. So this file is
// what `canSeeAdmin` users see on the OSS build only.
const Organizations: React.FC = () => {
const intl = useIntl();
const features = [
'organizations.upsell.feature.orgs',
'organizations.upsell.feature.members',
'organizations.upsell.feature.quotas',
'organizations.upsell.feature.isolation'
];
return (
<PageBox>
<div
style={{
// Use a viewport-relative min-height so flex centering has
// something to work against — percentage heights through
// PageBox's wrapper chain don't always resolve. The offset
// accounts for the global header + the page title bar.
minHeight: 'calc(100vh - 180px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Result
icon={
<IconFont
type="icon-org-outlined"
style={{ fontSize: 64, color: 'var(--ant-color-primary)' }}
/>
}
title={intl.formatMessage({ id: 'organizations.upsell.title' })}
subTitle={intl.formatMessage({ id: 'organizations.upsell.subtitle' })}
extra={
<div
style={{
maxWidth: 520,
margin: '0 auto',
textAlign: 'left'
}}
>
<Typography.Title level={5} style={{ marginBottom: 12 }}>
{intl.formatMessage({
id: 'organizations.upsell.featuresTitle'
})}
</Typography.Title>
<ul style={{ paddingLeft: 20, marginBottom: 24 }}>
{features.map((id) => (
<li key={id} style={{ marginBottom: 6 }}>
{intl.formatMessage({ id })}
</li>
))}
</ul>
<div style={{ textAlign: 'center' }}>
<Button
type="primary"
href="https://gpustack.ai/enterprise"
target="_blank"
rel="noreferrer"
>
{intl.formatMessage({ id: 'organizations.upsell.cta' })}
</Button>
</div>
</div>
}
/>
</div>
</PageBox>
);
};
export default Organizations;