feat: improve model rate limit tracking
This commit is contained in:
+34
-8
@@ -18,6 +18,7 @@ import type {
|
||||
GatewayWalletTransaction,
|
||||
IntegrationPlatform,
|
||||
ModelCatalogResponse,
|
||||
ModelRateLimitStatus,
|
||||
PlatformModel,
|
||||
PricingRule,
|
||||
PricingRuleSet,
|
||||
@@ -54,6 +55,7 @@ import {
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
listModelCatalog,
|
||||
listModelRateLimitStatuses,
|
||||
listModels,
|
||||
listPlayableApiKeys,
|
||||
listPlayableModels,
|
||||
@@ -140,6 +142,7 @@ type DataKey =
|
||||
| 'runnerPolicy'
|
||||
| 'runtimePolicySets'
|
||||
| 'rateLimitWindows'
|
||||
| 'modelRateLimits'
|
||||
| 'tenants'
|
||||
| 'users'
|
||||
| 'userGroups'
|
||||
@@ -183,6 +186,7 @@ export function App() {
|
||||
const [accessRules, setAccessRules] = useState<GatewayAccessRule[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<GatewayAuditLog[]>([]);
|
||||
const [rateLimitWindows, setRateLimitWindows] = useState<RateLimitWindow[]>([]);
|
||||
const [modelRateLimits, setModelRateLimits] = useState<ModelRateLimitStatus[]>([]);
|
||||
const [tenants, setTenants] = useState<GatewayTenant[]>([]);
|
||||
const [users, setUsers] = useState<GatewayUser[]>([]);
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([]);
|
||||
@@ -239,6 +243,23 @@ export function App() {
|
||||
useEffect(() => {
|
||||
void ensureRouteData(token);
|
||||
}, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]);
|
||||
useEffect(() => {
|
||||
if (!token || activePage !== 'admin' || adminSection !== 'platforms') return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
void Promise.all([listModelRateLimitStatuses(token), listPlatforms(token)])
|
||||
.then(([rateLimitResponse, platformResponse]) => {
|
||||
setModelRateLimits(rateLimitResponse.items);
|
||||
setPlatforms(platformResponse.items);
|
||||
loadedDataKeysRef.current.add('modelRateLimits');
|
||||
loadedDataKeysRef.current.add('platforms');
|
||||
})
|
||||
.catch(() => {
|
||||
loadedDataKeysRef.current.delete('modelRateLimits');
|
||||
loadedDataKeysRef.current.delete('platforms');
|
||||
});
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activePage, adminSection, token]);
|
||||
useEffect(() => {
|
||||
function handlePopState() {
|
||||
applyRoute(parseAppRoute());
|
||||
@@ -278,9 +299,10 @@ export function App() {
|
||||
platforms,
|
||||
pricingRules,
|
||||
pricingRuleSets,
|
||||
providers,
|
||||
rateLimitWindows,
|
||||
runtimePolicySets,
|
||||
providers,
|
||||
rateLimitWindows,
|
||||
modelRateLimits,
|
||||
runtimePolicySets,
|
||||
taskResult,
|
||||
tasks,
|
||||
tenants,
|
||||
@@ -288,7 +310,7 @@ export function App() {
|
||||
users,
|
||||
walletAccounts,
|
||||
walletTransactions,
|
||||
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
|
||||
}), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, modelRateLimits, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runnerPolicy, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]);
|
||||
|
||||
async function refresh(nextToken = token) {
|
||||
await ensureRouteData(nextToken, true);
|
||||
@@ -392,6 +414,9 @@ export function App() {
|
||||
case 'rateLimitWindows':
|
||||
setRateLimitWindows((await listRateLimitWindows(nextToken)).items);
|
||||
return;
|
||||
case 'modelRateLimits':
|
||||
setModelRateLimits((await listModelRateLimitStatuses(nextToken)).items);
|
||||
return;
|
||||
case 'tenants':
|
||||
setTenants((await listTenants(nextToken)).items);
|
||||
return;
|
||||
@@ -514,7 +539,7 @@ export function App() {
|
||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.platformId
|
||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||
@@ -533,7 +558,7 @@ export function App() {
|
||||
await deletePlatform(token, platformId);
|
||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台已删除。');
|
||||
} catch (err) {
|
||||
@@ -788,6 +813,7 @@ export function App() {
|
||||
setAccessRules([]);
|
||||
setAuditLogs([]);
|
||||
setRateLimitWindows([]);
|
||||
setModelRateLimits([]);
|
||||
setTenants([]);
|
||||
setUsers([]);
|
||||
setUserGroups([]);
|
||||
@@ -1132,7 +1158,7 @@ function dataKeysForRoute(
|
||||
if (activePage !== 'admin') return [];
|
||||
switch (adminSection) {
|
||||
case 'overview':
|
||||
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'tenants', 'users', 'userGroups', 'accessRules'];
|
||||
return ['platforms', 'models', 'providers', 'pricingRules', 'runtimePolicySets', 'rateLimitWindows', 'modelRateLimits', 'tenants', 'users', 'userGroups', 'accessRules'];
|
||||
case 'globalModels':
|
||||
return ['providers'];
|
||||
case 'pricing':
|
||||
@@ -1142,7 +1168,7 @@ function dataKeysForRoute(
|
||||
case 'baseModels':
|
||||
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
|
||||
case 'platforms':
|
||||
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig'];
|
||||
return ['platforms', 'models', 'modelRateLimits', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig'];
|
||||
case 'tenants':
|
||||
return ['tenants', 'userGroups'];
|
||||
case 'users':
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
IntegrationPlatform,
|
||||
ListResponse,
|
||||
ModelCatalogResponse,
|
||||
ModelRateLimitStatus,
|
||||
PlatformModel,
|
||||
PlayableGatewayApiKey,
|
||||
PricingRule,
|
||||
@@ -696,6 +697,10 @@ export async function listRateLimitWindows(token: string): Promise<ListResponse<
|
||||
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
|
||||
}
|
||||
|
||||
export async function listModelRateLimitStatuses(token: string): Promise<ListResponse<ModelRateLimitStatus>> {
|
||||
return request<ListResponse<ModelRateLimitStatus>>('/api/admin/runtime/model-rate-limits', { token });
|
||||
}
|
||||
|
||||
export async function getNetworkProxyConfig(token: string): Promise<GatewayNetworkProxyConfig> {
|
||||
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
GatewayWalletTransaction,
|
||||
IntegrationPlatform,
|
||||
ModelCatalogResponse,
|
||||
ModelRateLimitStatus,
|
||||
PlatformModel,
|
||||
PricingRule,
|
||||
PricingRuleSet,
|
||||
@@ -35,6 +36,7 @@ export interface ConsoleData {
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
providers: CatalogProvider[];
|
||||
rateLimitWindows: RateLimitWindow[];
|
||||
modelRateLimits: ModelRateLimitStatus[];
|
||||
runtimePolicySets: RuntimePolicySet[];
|
||||
taskResult: GatewayTask | null;
|
||||
tasks: GatewayTask[];
|
||||
|
||||
@@ -137,8 +137,9 @@ export function AdminPage(props: {
|
||||
<PlatformManagementPanel
|
||||
baseModels={props.data.baseModels}
|
||||
message={props.operationMessage}
|
||||
networkProxyConfig={props.data.networkProxyConfig}
|
||||
platformModels={props.data.models}
|
||||
networkProxyConfig={props.data.networkProxyConfig}
|
||||
modelRateLimits={props.data.modelRateLimits}
|
||||
platformModels={props.data.models}
|
||||
platforms={props.data.platforms}
|
||||
pricingRuleSets={props.data.pricingRuleSets}
|
||||
providers={props.data.providers}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
||||
import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts';
|
||||
import { Boxes, CheckCircle2, Gauge, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
||||
import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, ModelRateLimitStatus, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
import type { LoadState, PlatformWithModelsInput } from '../../types';
|
||||
import {
|
||||
@@ -22,8 +22,9 @@ import { ModelCatalogCard } from './ModelCatalogCard';
|
||||
|
||||
export function PlatformManagementPanel(props: {
|
||||
baseModels: BaseModelCatalogItem[];
|
||||
message: string;
|
||||
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
|
||||
message: string;
|
||||
modelRateLimits: ModelRateLimitStatus[];
|
||||
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
|
||||
platforms: IntegrationPlatform[];
|
||||
platformModels: PlatformModel[];
|
||||
pricingRuleSets: PricingRuleSet[];
|
||||
@@ -33,8 +34,9 @@ export function PlatformManagementPanel(props: {
|
||||
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
|
||||
}) {
|
||||
const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? '';
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'platforms' | 'models'>('platforms');
|
||||
const [viewMode, setViewMode] = useState<'platforms' | 'models' | 'limits'>('platforms');
|
||||
const [modelQuery, setModelQuery] = useState('');
|
||||
const [selectedPlatformId, setSelectedPlatformId] = useState('');
|
||||
const [validationMessage, setValidationMessage] = useState('');
|
||||
@@ -72,6 +74,11 @@ export function PlatformManagementPanel(props: {
|
||||
});
|
||||
}, [modelQuery, platformMap, props.platformModels, selectedModelPlatformId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
function openCreateDialog() {
|
||||
const provider = providerOptions[0] ?? '';
|
||||
const providerName = providerDisplayName(provider, providerMap);
|
||||
@@ -166,12 +173,14 @@ export function PlatformManagementPanel(props: {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="platformViewTabs">
|
||||
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}>平台视图</button>
|
||||
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}>模型视图</button>
|
||||
</div>
|
||||
{viewMode === 'platforms' ? (
|
||||
<PlatformTable
|
||||
<div className="platformViewTabs">
|
||||
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}>平台视图</button>
|
||||
<button type="button" data-active={viewMode === 'models'} onClick={() => setViewMode('models')}>模型视图</button>
|
||||
<button type="button" data-active={viewMode === 'limits'} onClick={() => setViewMode('limits')}>实时限流</button>
|
||||
</div>
|
||||
{viewMode === 'platforms' ? (
|
||||
<PlatformTable
|
||||
now={now}
|
||||
platformModelCount={platformModelCount}
|
||||
platforms={props.platforms}
|
||||
providerMap={providerMap}
|
||||
@@ -180,8 +189,8 @@ export function PlatformManagementPanel(props: {
|
||||
onCreate={openCreateDialog}
|
||||
onEdit={openEditDialog}
|
||||
/>
|
||||
) : (
|
||||
<PlatformModelTable
|
||||
) : viewMode === 'models' ? (
|
||||
<PlatformModelTable
|
||||
baseModels={props.baseModels}
|
||||
modelQuery={modelQuery}
|
||||
models={filteredPlatformModels}
|
||||
@@ -190,9 +199,12 @@ export function PlatformManagementPanel(props: {
|
||||
platforms={props.platforms}
|
||||
providerMap={providerMap}
|
||||
onModelQueryChange={setModelQuery}
|
||||
onPlatformChange={setSelectedPlatformId}
|
||||
/>
|
||||
)}
|
||||
now={now}
|
||||
onPlatformChange={setSelectedPlatformId}
|
||||
/>
|
||||
) : (
|
||||
<RateLimitStatusTable statuses={props.modelRateLimits} platformMap={platformMap} now={now} />
|
||||
)}
|
||||
{props.message && <p className="formMessage">{props.message}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -391,6 +403,7 @@ function ModelBindingPolicy(props: { form: PlatformWizardForm; onChange: (value:
|
||||
}
|
||||
|
||||
function PlatformTable(props: {
|
||||
now: number;
|
||||
platformModelCount: Map<string, number>;
|
||||
platforms: IntegrationPlatform[];
|
||||
providerMap: Map<string, CatalogProvider>;
|
||||
@@ -428,6 +441,7 @@ function PlatformTable(props: {
|
||||
const pricing = platformPricingSummary(platform, props.pricingRuleSets);
|
||||
const rateLimit = platformRateLimitSummary(platform.rateLimitPolicy);
|
||||
const runtime = platformRuntimeSummary(platform);
|
||||
const platformCooldownMs = cooldownRemainingMs(platform.cooldownUntil, props.now);
|
||||
return (
|
||||
<TableRow key={platform.id}>
|
||||
<TableCell>
|
||||
@@ -458,8 +472,14 @@ function PlatformTable(props: {
|
||||
<TableCell>{props.platformModelCount.get(platform.id) ?? 0}</TableCell>
|
||||
<TableCell>
|
||||
<span className="platformTableName">
|
||||
<strong><Badge variant={platform.status === 'enabled' ? 'success' : 'secondary'}>{platform.status}</Badge></strong>
|
||||
<small>{runtime}</small>
|
||||
<strong>
|
||||
{platformCooldownMs > 0 ? (
|
||||
<Badge variant="warning">冷却中</Badge>
|
||||
) : (
|
||||
<Badge variant={platform.status === 'enabled' ? 'success' : 'secondary'}>{platform.status}</Badge>
|
||||
)}
|
||||
</strong>
|
||||
<small>{platformCooldownMs > 0 ? `剩余 ${formatCooldownRemaining(platformCooldownMs)}` : runtime}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -487,6 +507,7 @@ function PlatformModelTable(props: {
|
||||
platformMap: Map<string, IntegrationPlatform>;
|
||||
platforms: IntegrationPlatform[];
|
||||
providerMap: Map<string, CatalogProvider>;
|
||||
now: number;
|
||||
onModelQueryChange: (value: string) => void;
|
||||
onPlatformChange: (value: string) => void;
|
||||
}) {
|
||||
@@ -520,12 +541,14 @@ function PlatformModelTable(props: {
|
||||
const provider = platform ? props.providerMap.get(platform.provider) : undefined;
|
||||
const baseModel = findBaseModelForPlatformModel(platform, props.baseModels, model);
|
||||
const modelIconPath = readPlatformModelIconPath(model, baseModel);
|
||||
const modelCooldownMs = cooldownRemainingMs(model.cooldownUntil, props.now);
|
||||
return (
|
||||
<ModelCatalogCard
|
||||
key={model.id}
|
||||
badges={[
|
||||
<Badge variant="outline">{model.modelType.join(', ')}</Badge>,
|
||||
<Badge variant={model.enabled ? 'success' : 'secondary'}>{model.enabled ? 'enabled' : 'disabled'}</Badge>,
|
||||
<Badge key="type" variant="outline">{model.modelType.join(', ')}</Badge>,
|
||||
...(modelCooldownMs > 0 ? [<Badge key="cooldown" variant="warning">冷却中 · {formatCooldownRemaining(modelCooldownMs)}</Badge>] : []),
|
||||
<Badge key="enabled" variant={model.enabled ? 'success' : 'secondary'}>{model.enabled ? 'enabled' : 'disabled'}</Badge>,
|
||||
]}
|
||||
chips={platformModelChips(model)}
|
||||
iconPath={modelIconPath || provider?.iconPath}
|
||||
@@ -545,6 +568,62 @@ function PlatformModelTable(props: {
|
||||
<EmptyState title="没有匹配的模型" description="换个平台或搜索关键词试试。" />
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platformMap: Map<string, IntegrationPlatform>; now: number }) {
|
||||
if (!props.statuses.length) {
|
||||
return <EmptyState title="暂无限流状态" description="模型产生请求后会在这里显示实时 RPM、TPM 和并发窗口。" />;
|
||||
}
|
||||
return (
|
||||
<section className="platformLimitView">
|
||||
<div className="platformLimitHeader">
|
||||
<span><Gauge size={15} />按综合满载率从高到低排序</span>
|
||||
<small>每 3 秒刷新一次;TPM 显示已结算 + 预占。</small>
|
||||
</div>
|
||||
<div className="platformLimitTableViewport">
|
||||
<Table className="platformDataTable platformLimitTable">
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>平台</TableHead>
|
||||
<TableHead>并发</TableHead>
|
||||
<TableHead>TPM</TableHead>
|
||||
<TableHead>RPM</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>满载率</TableHead>
|
||||
</TableRow>
|
||||
{props.statuses.map((status) => {
|
||||
const platform = props.platformMap.get(status.platformId);
|
||||
return (
|
||||
<TableRow key={status.platformModelId}>
|
||||
<TableCell>
|
||||
<span className="platformTableName">
|
||||
<strong>{status.displayName || status.modelAlias || status.modelName}</strong>
|
||||
<small>{status.providerModelName || status.modelName}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="platformTableName">
|
||||
<strong>{platform ? platformDisplayName(platform) : status.platformName}</strong>
|
||||
<small>{status.provider}</small>
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{metricCell(status.concurrent)}</TableCell>
|
||||
<TableCell>{metricCell(status.tpm, true)}</TableCell>
|
||||
<TableCell>{metricCell(status.rpm)}</TableCell>
|
||||
<TableCell>{modelRuntimeStatusCell(status, props.now)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="rateLoadCell">
|
||||
<strong>{formatPercent(status.loadRatio)}</strong>
|
||||
<span className="rateLoadTrack"><i style={{ width: `${Math.min(status.loadRatio * 100, 100)}%` }} /></span>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1112,7 +1191,63 @@ function rateLimitMetricText(metric: string) {
|
||||
concurrent: '并发',
|
||||
queue_size: '队列',
|
||||
};
|
||||
return labels[metric] ?? metric;
|
||||
return labels[metric] ?? metric;
|
||||
}
|
||||
|
||||
function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false) {
|
||||
if (!metric.limited) return <span className="rateMetricCell"><strong>{formatLimit(metric.currentValue)} / 不限</strong><small>未配置上限</small></span>;
|
||||
return (
|
||||
<span className="rateMetricCell">
|
||||
<strong>{formatLimit(metric.currentValue)} / {formatLimit(metric.limitValue)}</strong>
|
||||
<small>{includeReserved && metric.reservedValue > 0 ? `已用 ${formatLimit(metric.usedValue)} · 预占 ${formatLimit(metric.reservedValue)}` : `窗口 ${formatPercent(metric.ratio)}`}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function modelRuntimeStatusCell(status: ModelRateLimitStatus, now: number) {
|
||||
const modelCooldownMs = cooldownRemainingMs(status.modelCooldownUntil, now);
|
||||
const platformCooldownMs = cooldownRemainingMs(status.platformCooldownUntil, now);
|
||||
if (modelCooldownMs > 0) {
|
||||
return (
|
||||
<span className="platformTableName">
|
||||
<strong><Badge variant="warning">模型冷却中</Badge></strong>
|
||||
<small>剩余 {formatCooldownRemaining(modelCooldownMs)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (platformCooldownMs > 0) {
|
||||
return (
|
||||
<span className="platformTableName">
|
||||
<strong><Badge variant="warning">平台冷却中</Badge></strong>
|
||||
<small>剩余 {formatCooldownRemaining(platformCooldownMs)}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="platformTableName">
|
||||
<strong><Badge variant={status.enabled ? 'success' : 'secondary'}>{status.enabled ? '可用' : '已停用'}</Badge></strong>
|
||||
<small>{status.enabled ? '参与路由' : '不参与路由'}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function cooldownRemainingMs(cooldownUntil: string | undefined, now: number) {
|
||||
if (!cooldownUntil) return 0;
|
||||
const until = Date.parse(cooldownUntil);
|
||||
if (!Number.isFinite(until)) return 0;
|
||||
return Math.max(until - now, 0);
|
||||
}
|
||||
|
||||
function formatCooldownRemaining(milliseconds: number) {
|
||||
const minutes = milliseconds / 60000;
|
||||
if (minutes >= 1) return `${trimNumber(Math.ceil(minutes * 10) / 10)} 分钟`;
|
||||
const seconds = Math.ceil(milliseconds / 1000);
|
||||
return `${Math.max(seconds, 1)} 秒`;
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0%';
|
||||
return `${trimNumber(value * 100)}%`;
|
||||
}
|
||||
|
||||
function platformRuntimeSummary(platform: IntegrationPlatform) {
|
||||
|
||||
@@ -964,6 +964,104 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.platformLimitView {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
max-height: calc(100dvh - 220px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.platformLimitHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.platformLimitHeader span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.platformLimitTableViewport {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-height: calc(100dvh - 270px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.platformLimitTable {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
overflow: visible;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.platformLimitTable .shTableHeader {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.platformLimitTable .shTableRow {
|
||||
grid-template-columns: clamp(150px, 16vw, 220px) minmax(148px, 1fr) minmax(104px, max-content) minmax(136px, max-content) minmax(122px, max-content) minmax(132px, max-content) minmax(128px, max-content);
|
||||
min-width: 920px;
|
||||
}
|
||||
|
||||
.platformLimitTable .shTableHead,
|
||||
.platformLimitTable .shTableCell {
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.rateMetricCell,
|
||||
.rateLoadCell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rateMetricCell strong,
|
||||
.rateLoadCell strong {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.rateMetricCell small {
|
||||
overflow: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rateLoadTrack {
|
||||
display: block;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #eef2f6;
|
||||
}
|
||||
|
||||
.rateLoadTrack i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #0f766e;
|
||||
}
|
||||
|
||||
.platformModelToolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.6fr) minmax(260px, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user