feat: add realtime load admin page
This commit is contained in:
@@ -245,7 +245,7 @@ export function App() {
|
|||||||
void ensureRouteData(token);
|
void ensureRouteData(token);
|
||||||
}, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]);
|
}, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || activePage !== 'admin' || adminSection !== 'platforms') return undefined;
|
if (!token || activePage !== 'admin' || adminSection !== 'realtimeLoad') return undefined;
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void Promise.all([listModelRateLimitStatuses(token), listPlatforms(token)])
|
void Promise.all([listModelRateLimitStatuses(token), listPlatforms(token)])
|
||||||
.then(([rateLimitResponse, platformResponse]) => {
|
.then(([rateLimitResponse, platformResponse]) => {
|
||||||
@@ -1179,7 +1179,9 @@ function dataKeysForRoute(
|
|||||||
case 'baseModels':
|
case 'baseModels':
|
||||||
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
|
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
|
||||||
case 'platforms':
|
case 'platforms':
|
||||||
return ['platforms', 'models', 'modelRateLimits', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig'];
|
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig'];
|
||||||
|
case 'realtimeLoad':
|
||||||
|
return ['platforms', 'modelRateLimits'];
|
||||||
case 'tenants':
|
case 'tenants':
|
||||||
return ['tenants', 'userGroups'];
|
return ['tenants', 'userGroups'];
|
||||||
case 'users':
|
case 'users':
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export const primaryModules = [
|
|||||||
{
|
{
|
||||||
title: '管理工作台',
|
title: '管理工作台',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
description: '租户、用户、用户组、全局模型、平台、限流、重试、队列和回调 outbox。',
|
description: '租户、用户、用户组、全局模型、平台、实时负载、重试、队列和回调 outbox。',
|
||||||
items: ['租户管理', '用户管理', '用户组策略', '全局模型', '队列限流'],
|
items: ['租户管理', '用户管理', '用户组策略', '全局模型', '实时负载'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'API 文档',
|
title: 'API 文档',
|
||||||
@@ -50,6 +50,7 @@ export const adminPages = [
|
|||||||
{ title: '用户组策略', path: '/admin/user-groups', description: '用户组成员、充值折扣、调用折扣、TPM/RPM/并发和队列优先级。' },
|
{ title: '用户组策略', path: '/admin/user-groups', description: '用户组成员、充值折扣、调用折扣、TPM/RPM/并发和队列优先级。' },
|
||||||
{ title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' },
|
{ title: '全局模型配置', path: '/admin/models/global', description: '基准模型库、能力 schema、基准定价和默认限流模板。' },
|
||||||
{ title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' },
|
{ title: '平台管理', path: '/admin/platforms', description: '平台 CRUD、凭证、默认折扣、平台模型、限流和重试策略。' },
|
||||||
|
{ title: '实时负载', path: '/admin/realtime-load', description: '按平台模型查看实时 RPM、TPM、并发、排队和冷却状态。' },
|
||||||
{ title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' },
|
{ title: '运行与队列', path: '/admin/runtime/queues', description: 'TPM/RPM 窗口、并发 lease、cooldown、任务恢复和队列积压。' },
|
||||||
{ title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' },
|
{ title: '回调与结算', path: '/admin/callbacks', description: '任务进度 callback outbox、结算 outbox、失败重试和手动 replay。' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { TenantsPanel, UserGroupsPanel, UsersPanel } from './admin/IdentityManag
|
|||||||
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
import { PlatformManagementPanel } from './admin/PlatformManagementPanel';
|
||||||
import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
import { PricingRulesPanel } from './admin/PricingRulesPanel';
|
||||||
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
|
import { ProviderManagementPanel } from './admin/ProviderManagementPanel';
|
||||||
|
import { RealtimeLoadPanel } from './admin/RealtimeLoadPanel';
|
||||||
import { RuntimePoliciesPanel } from './admin/RuntimePoliciesPanel';
|
import { RuntimePoliciesPanel } from './admin/RuntimePoliciesPanel';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
@@ -34,6 +35,7 @@ const tabs = [
|
|||||||
{ value: 'runtime', label: '运行策略', icon: <ShieldCheck size={15} /> },
|
{ value: 'runtime', label: '运行策略', icon: <ShieldCheck size={15} /> },
|
||||||
{ value: 'baseModels', label: '基准模型库', icon: <Boxes size={15} /> },
|
{ value: 'baseModels', label: '基准模型库', icon: <Boxes size={15} /> },
|
||||||
{ value: 'platforms', label: '平台管理', icon: <ServerCog size={15} /> },
|
{ value: 'platforms', label: '平台管理', icon: <ServerCog size={15} /> },
|
||||||
|
{ value: 'realtimeLoad', label: '实时负载', icon: <Gauge size={15} /> },
|
||||||
{ value: 'tenants', label: '租户', icon: <Building2 size={15} /> },
|
{ value: 'tenants', label: '租户', icon: <Building2 size={15} /> },
|
||||||
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
{ value: 'users', label: '用户', icon: <UsersRound size={15} /> },
|
||||||
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
{ value: 'userGroups', label: '用户组', icon: <UsersRound size={15} /> },
|
||||||
@@ -138,8 +140,6 @@ export function AdminPage(props: {
|
|||||||
baseModels={props.data.baseModels}
|
baseModels={props.data.baseModels}
|
||||||
message={props.operationMessage}
|
message={props.operationMessage}
|
||||||
networkProxyConfig={props.data.networkProxyConfig}
|
networkProxyConfig={props.data.networkProxyConfig}
|
||||||
modelRateLimits={props.data.modelRateLimits}
|
|
||||||
modelRateLimitsUpdatedAt={props.data.modelRateLimitsUpdatedAt}
|
|
||||||
platformModels={props.data.models}
|
platformModels={props.data.models}
|
||||||
platforms={props.data.platforms}
|
platforms={props.data.platforms}
|
||||||
pricingRuleSets={props.data.pricingRuleSets}
|
pricingRuleSets={props.data.pricingRuleSets}
|
||||||
@@ -149,6 +149,13 @@ export function AdminPage(props: {
|
|||||||
onSavePlatform={props.onSavePlatform}
|
onSavePlatform={props.onSavePlatform}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{props.section === 'realtimeLoad' && (
|
||||||
|
<RealtimeLoadPanel
|
||||||
|
modelRateLimits={props.data.modelRateLimits}
|
||||||
|
modelRateLimitsUpdatedAt={props.data.modelRateLimitsUpdatedAt}
|
||||||
|
platforms={props.data.platforms}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
|
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
|
||||||
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
{props.section === 'users' && <UsersPanel {...identityPanelProps(props)} />}
|
||||||
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
{props.section === 'userGroups' && <UserGroupsPanel {...identityPanelProps(props)} />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Boxes, CheckCircle2, Gauge, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react';
|
import { Boxes, CheckCircle2, 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 type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, 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 { 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 type { LoadState, PlatformWithModelsInput } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -23,8 +23,6 @@ import { ModelCatalogCard } from './ModelCatalogCard';
|
|||||||
export function PlatformManagementPanel(props: {
|
export function PlatformManagementPanel(props: {
|
||||||
baseModels: BaseModelCatalogItem[];
|
baseModels: BaseModelCatalogItem[];
|
||||||
message: string;
|
message: string;
|
||||||
modelRateLimits: ModelRateLimitStatus[];
|
|
||||||
modelRateLimitsUpdatedAt: number | null;
|
|
||||||
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
|
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
|
||||||
platforms: IntegrationPlatform[];
|
platforms: IntegrationPlatform[];
|
||||||
platformModels: PlatformModel[];
|
platformModels: PlatformModel[];
|
||||||
@@ -37,7 +35,7 @@ export function PlatformManagementPanel(props: {
|
|||||||
const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? '';
|
const defaultProvider = props.providers[0]?.providerKey ?? props.baseModels[0]?.providerKey ?? '';
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<'platforms' | 'models' | 'limits'>('platforms');
|
const [viewMode, setViewMode] = useState<'platforms' | 'models'>('platforms');
|
||||||
const [modelQuery, setModelQuery] = useState('');
|
const [modelQuery, setModelQuery] = useState('');
|
||||||
const [selectedPlatformId, setSelectedPlatformId] = useState('');
|
const [selectedPlatformId, setSelectedPlatformId] = useState('');
|
||||||
const [validationMessage, setValidationMessage] = useState('');
|
const [validationMessage, setValidationMessage] = useState('');
|
||||||
@@ -174,13 +172,12 @@ export function PlatformManagementPanel(props: {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="platformViewTabs">
|
<div className="platformViewTabs">
|
||||||
<button type="button" data-active={viewMode === 'platforms'} onClick={() => setViewMode('platforms')}>平台视图</button>
|
<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 === 'models'} onClick={() => setViewMode('models')}>模型视图</button>
|
||||||
<button type="button" data-active={viewMode === 'limits'} onClick={() => setViewMode('limits')}>实时限流</button>
|
</div>
|
||||||
</div>
|
{viewMode === 'platforms' ? (
|
||||||
{viewMode === 'platforms' ? (
|
<PlatformTable
|
||||||
<PlatformTable
|
|
||||||
now={now}
|
now={now}
|
||||||
platformModelCount={platformModelCount}
|
platformModelCount={platformModelCount}
|
||||||
platforms={props.platforms}
|
platforms={props.platforms}
|
||||||
@@ -190,8 +187,8 @@ export function PlatformManagementPanel(props: {
|
|||||||
onCreate={openCreateDialog}
|
onCreate={openCreateDialog}
|
||||||
onEdit={openEditDialog}
|
onEdit={openEditDialog}
|
||||||
/>
|
/>
|
||||||
) : viewMode === 'models' ? (
|
) : (
|
||||||
<PlatformModelTable
|
<PlatformModelTable
|
||||||
baseModels={props.baseModels}
|
baseModels={props.baseModels}
|
||||||
modelQuery={modelQuery}
|
modelQuery={modelQuery}
|
||||||
models={filteredPlatformModels}
|
models={filteredPlatformModels}
|
||||||
@@ -203,13 +200,6 @@ export function PlatformManagementPanel(props: {
|
|||||||
now={now}
|
now={now}
|
||||||
onPlatformChange={setSelectedPlatformId}
|
onPlatformChange={setSelectedPlatformId}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<RateLimitStatusTable
|
|
||||||
now={now}
|
|
||||||
platformMap={platformMap}
|
|
||||||
statuses={props.modelRateLimits}
|
|
||||||
updatedAt={props.modelRateLimitsUpdatedAt}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{props.message && <p className="formMessage">{props.message}</p>}
|
{props.message && <p className="formMessage">{props.message}</p>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -574,65 +564,6 @@ function PlatformModelTable(props: {
|
|||||||
<EmptyState title="没有匹配的模型" description="换个平台或搜索关键词试试。" />
|
<EmptyState title="没有匹配的模型" description="换个平台或搜索关键词试试。" />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platformMap: Map<string, IntegrationPlatform>; now: number; updatedAt: number | null }) {
|
|
||||||
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 显示已结算 + 预占;最新更新时间 {formatTimeOfDay(props.updatedAt)}。</small>
|
|
||||||
</div>
|
|
||||||
<div className="platformLimitTableViewport">
|
|
||||||
<Table className="platformDataTable platformLimitTable">
|
|
||||||
<TableRow className="shTableHeader">
|
|
||||||
<TableHead>模型</TableHead>
|
|
||||||
<TableHead>平台</TableHead>
|
|
||||||
<TableHead className="platformLimitMetricHead platformLimitNumberHead" title="正在执行 / 并发上限 / 排队任务">
|
|
||||||
<span>并发</span>
|
|
||||||
<small>正在执行 / 并发 / 排队</small>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="platformLimitNumberHead">TPM</TableHead>
|
|
||||||
<TableHead className="platformLimitNumberHead">RPM</TableHead>
|
|
||||||
<TableHead className="platformLimitStatusHead">状态</TableHead>
|
|
||||||
<TableHead className="platformLimitNumberHead">满载率</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 className="platformLimitNumberCell">{concurrencyMetricCell(status)}</TableCell>
|
|
||||||
<TableCell className="platformLimitNumberCell">{metricCell(status.tpm, true)}</TableCell>
|
|
||||||
<TableCell className="platformLimitNumberCell">{metricCell(status.rpm)}</TableCell>
|
|
||||||
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, props.now)}</TableCell>
|
|
||||||
<TableCell className="platformLimitNumberCell">
|
|
||||||
<span className="rateLoadCell" data-overloaded={status.loadRatio > 0.8 ? 'true' : undefined}>
|
|
||||||
<strong>{formatPercent(status.loadRatio)}</strong>
|
|
||||||
<span className="rateLoadTrack"><i style={{ width: `${Math.min(status.loadRatio * 100, 100)}%` }} /></span>
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1200,65 +1131,7 @@ function rateLimitMetricText(metric: string) {
|
|||||||
concurrent: '并发',
|
concurrent: '并发',
|
||||||
queue_size: '队列',
|
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>{includeReserved ? reservedMetricText(metric) : '未配置上限'}</small></span>;
|
|
||||||
return (
|
|
||||||
<span className="rateMetricCell">
|
|
||||||
<strong>{formatLimit(metric.currentValue)} / {formatLimit(metric.limitValue)}</strong>
|
|
||||||
<small>{includeReserved ? reservedMetricText(metric) : `窗口 ${formatPercent(metric.ratio)}`}</small>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function concurrencyMetricCell(status: ModelRateLimitStatus) {
|
|
||||||
const queuedTasks = status.queuedTasks ?? 0;
|
|
||||||
const limitText = status.concurrent.limited ? formatLimit(status.concurrent.limitValue) : '不限';
|
|
||||||
return (
|
|
||||||
<span className="rateMetricCell" title="正在执行 / 并发上限 / 排队任务">
|
|
||||||
<strong>{formatLimit(status.concurrent.currentValue)} / {limitText} / {formatLimit(queuedTasks)}</strong>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reservedMetricText(metric: ModelRateLimitStatus['rpm']) {
|
|
||||||
return `已结算 ${formatLimit(metric.usedValue)} + 预占 ${formatLimit(metric.reservedValue)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeOfDay(timestamp: number | null) {
|
|
||||||
if (!timestamp) return '暂无';
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0');
|
|
||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
function cooldownRemainingMs(cooldownUntil: string | undefined, now: number) {
|
||||||
@@ -1275,11 +1148,6 @@ function formatCooldownRemaining(milliseconds: number) {
|
|||||||
return `${Math.max(seconds, 1)} 秒`;
|
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) {
|
function platformRuntimeSummary(platform: IntegrationPlatform) {
|
||||||
const retryPolicy = platform.retryPolicy ?? {};
|
const retryPolicy = platform.retryPolicy ?? {};
|
||||||
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
|
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Gauge } from 'lucide-react';
|
||||||
|
import type { IntegrationPlatform, ModelRateLimitStatus } from '@easyai-ai-gateway/contracts';
|
||||||
|
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||||
|
|
||||||
|
export function RealtimeLoadPanel(props: {
|
||||||
|
modelRateLimits: ModelRateLimitStatus[];
|
||||||
|
modelRateLimitsUpdatedAt: number | null;
|
||||||
|
platforms: IntegrationPlatform[];
|
||||||
|
}) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="pageStack">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div>
|
||||||
|
<CardTitle>实时负载</CardTitle>
|
||||||
|
<p className="mutedText">按平台模型查看实时 RPM、TPM、并发、排队、冷却和综合满载率。</p>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RateLimitStatusTable
|
||||||
|
now={now}
|
||||||
|
platformMap={platformMap}
|
||||||
|
statuses={props.modelRateLimits}
|
||||||
|
updatedAt={props.modelRateLimitsUpdatedAt}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platformMap: Map<string, IntegrationPlatform>; now: number; updatedAt: number | null }) {
|
||||||
|
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 显示已结算 + 预占;最新更新时间 {formatTimeOfDay(props.updatedAt)}。</small>
|
||||||
|
</div>
|
||||||
|
<div className="platformLimitTableViewport">
|
||||||
|
<Table className="platformDataTable platformLimitTable">
|
||||||
|
<TableRow className="shTableHeader">
|
||||||
|
<TableHead>模型</TableHead>
|
||||||
|
<TableHead>平台</TableHead>
|
||||||
|
<TableHead className="platformLimitMetricHead platformLimitNumberHead" title="正在执行 / 并发上限 / 排队任务">
|
||||||
|
<span>并发</span>
|
||||||
|
<small>正在执行 / 并发 / 排队</small>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="platformLimitNumberHead">TPM</TableHead>
|
||||||
|
<TableHead className="platformLimitNumberHead">RPM</TableHead>
|
||||||
|
<TableHead className="platformLimitStatusHead">状态</TableHead>
|
||||||
|
<TableHead className="platformLimitNumberHead">满载率</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 className="platformLimitNumberCell">{concurrencyMetricCell(status)}</TableCell>
|
||||||
|
<TableCell className="platformLimitNumberCell">{metricCell(status.tpm, true)}</TableCell>
|
||||||
|
<TableCell className="platformLimitNumberCell">{metricCell(status.rpm)}</TableCell>
|
||||||
|
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, props.now)}</TableCell>
|
||||||
|
<TableCell className="platformLimitNumberCell">
|
||||||
|
<span className="rateLoadCell" data-overloaded={status.loadRatio > 0.8 ? 'true' : undefined}>
|
||||||
|
<strong>{formatPercent(status.loadRatio)}</strong>
|
||||||
|
<span className="rateLoadTrack"><i style={{ width: `${Math.min(status.loadRatio * 100, 100)}%` }} /></span>
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformDisplayName(platform: IntegrationPlatform) {
|
||||||
|
return platform.internalName?.trim() || platform.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false) {
|
||||||
|
if (!metric.limited) return <span className="rateMetricCell"><strong>{formatLimit(metric.currentValue)} / 不限</strong><small>{includeReserved ? reservedMetricText(metric) : '未配置上限'}</small></span>;
|
||||||
|
return (
|
||||||
|
<span className="rateMetricCell">
|
||||||
|
<strong>{formatLimit(metric.currentValue)} / {formatLimit(metric.limitValue)}</strong>
|
||||||
|
<small>{includeReserved ? reservedMetricText(metric) : `窗口 ${formatPercent(metric.ratio)}`}</small>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function concurrencyMetricCell(status: ModelRateLimitStatus) {
|
||||||
|
const queuedTasks = status.queuedTasks ?? 0;
|
||||||
|
const limitText = status.concurrent.limited ? formatLimit(status.concurrent.limitValue) : '不限';
|
||||||
|
return (
|
||||||
|
<span className="rateMetricCell" title="正在执行 / 并发上限 / 排队任务">
|
||||||
|
<strong>{formatLimit(status.concurrent.currentValue)} / {limitText} / {formatLimit(queuedTasks)}</strong>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservedMetricText(metric: ModelRateLimitStatus['rpm']) {
|
||||||
|
return `已结算 ${formatLimit(metric.usedValue)} + 预占 ${formatLimit(metric.reservedValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeOfDay(timestamp: number | null) {
|
||||||
|
if (!timestamp) return '暂无';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
|
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 formatLimit(value: number) {
|
||||||
|
if (Math.abs(value) >= 10000) return `${trimNumber(value / 10000)}万`;
|
||||||
|
if (Math.abs(value) >= 1000) return `${trimNumber(value / 1000)}k`;
|
||||||
|
return trimNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimNumber(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ const adminPaths: Record<AdminSection, string> = {
|
|||||||
baseModels: '/admin/base-models',
|
baseModels: '/admin/base-models',
|
||||||
pricing: '/admin/pricing',
|
pricing: '/admin/pricing',
|
||||||
platforms: '/admin/platforms',
|
platforms: '/admin/platforms',
|
||||||
|
realtimeLoad: '/admin/realtime-load',
|
||||||
tenants: '/admin/tenants',
|
tenants: '/admin/tenants',
|
||||||
users: '/admin/users',
|
users: '/admin/users',
|
||||||
userGroups: '/admin/user-groups',
|
userGroups: '/admin/user-groups',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AdminSection =
|
|||||||
| 'baseModels'
|
| 'baseModels'
|
||||||
| 'pricing'
|
| 'pricing'
|
||||||
| 'platforms'
|
| 'platforms'
|
||||||
|
| 'realtimeLoad'
|
||||||
| 'tenants'
|
| 'tenants'
|
||||||
| 'users'
|
| 'users'
|
||||||
| 'userGroups'
|
| 'userGroups'
|
||||||
|
|||||||
Reference in New Issue
Block a user