feat: add priority demotion controls
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
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';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Popover as AntPopover } from 'antd';
|
||||
import { CheckCircle2, Gauge, History, RotateCcw, SlidersHorizontal } from 'lucide-react';
|
||||
import type { IntegrationPlatform, ModelRateLimitStatus, PlatformDynamicPriorityUpdateRequest, PriorityDemotionRecord } from '@easyai-ai-gateway/contracts';
|
||||
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow } from '../../components/ui';
|
||||
|
||||
export function RealtimeLoadPanel(props: {
|
||||
modelRateLimits: ModelRateLimitStatus[];
|
||||
modelRateLimitsUpdatedAt: number | null;
|
||||
platforms: IntegrationPlatform[];
|
||||
onSavePlatformDynamicPriority: (platformId: string, input: PlatformDynamicPriorityUpdateRequest) => Promise<void>;
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [priorityDialog, setPriorityDialog] = useState<PriorityDialogState | null>(null);
|
||||
const [priorityError, setPriorityError] = useState('');
|
||||
const [prioritySaving, setPrioritySaving] = useState(false);
|
||||
const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -16,6 +21,50 @@ export function RealtimeLoadPanel(props: {
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
function openPriorityDialog(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) {
|
||||
setPriorityError('');
|
||||
setPriorityDialog({
|
||||
platform,
|
||||
status,
|
||||
value: formatPriority(platformEffectivePriority(status, platform)),
|
||||
});
|
||||
}
|
||||
|
||||
function closePriorityDialog() {
|
||||
if (prioritySaving) return;
|
||||
setPriorityDialog(null);
|
||||
setPriorityError('');
|
||||
}
|
||||
|
||||
async function submitPriorityForm(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!priorityDialog) return;
|
||||
const dynamicPriority = parsePriorityInput(priorityDialog.value);
|
||||
if (dynamicPriority === null) {
|
||||
setPriorityError('请输入大于等于 0 的整数。');
|
||||
return;
|
||||
}
|
||||
await savePriority(priorityDialog.status.platformId, { dynamicPriority });
|
||||
}
|
||||
|
||||
async function resetPriority() {
|
||||
if (!priorityDialog) return;
|
||||
await savePriority(priorityDialog.status.platformId, { reset: true });
|
||||
}
|
||||
|
||||
async function savePriority(platformId: string, input: PlatformDynamicPriorityUpdateRequest) {
|
||||
setPrioritySaving(true);
|
||||
setPriorityError('');
|
||||
try {
|
||||
await props.onSavePlatformDynamicPriority(platformId, input);
|
||||
setPriorityDialog(null);
|
||||
} catch (err) {
|
||||
setPriorityError(err instanceof Error ? err.message : '更新平台动态优先级失败');
|
||||
} finally {
|
||||
setPrioritySaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pageStack">
|
||||
<Card>
|
||||
@@ -31,14 +80,36 @@ export function RealtimeLoadPanel(props: {
|
||||
platformMap={platformMap}
|
||||
statuses={props.modelRateLimits}
|
||||
updatedAt={props.modelRateLimitsUpdatedAt}
|
||||
onAdjustPriority={openPriorityDialog}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PlatformPriorityDialog
|
||||
dialog={priorityDialog}
|
||||
error={priorityError}
|
||||
saving={prioritySaving}
|
||||
onClose={closePriorityDialog}
|
||||
onReset={resetPriority}
|
||||
onSubmit={submitPriorityForm}
|
||||
onValueChange={(value) => setPriorityDialog((current) => current ? { ...current, value } : current)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platformMap: Map<string, IntegrationPlatform>; now: number; updatedAt: number | null }) {
|
||||
type PriorityDialogState = {
|
||||
platform: IntegrationPlatform | undefined;
|
||||
status: ModelRateLimitStatus;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function RateLimitStatusTable(props: {
|
||||
statuses: ModelRateLimitStatus[];
|
||||
platformMap: Map<string, IntegrationPlatform>;
|
||||
now: number;
|
||||
updatedAt: number | null;
|
||||
onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void;
|
||||
}) {
|
||||
if (!props.statuses.length) {
|
||||
return <EmptyState title="暂无实时负载" description="模型产生请求后会在这里显示实时 RPM、TPM 和并发窗口。" />;
|
||||
}
|
||||
@@ -53,6 +124,8 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
|
||||
<TableRow className="shTableHeader">
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>平台</TableHead>
|
||||
<TableHead className="platformLimitNumberHead">平台优先级</TableHead>
|
||||
<TableHead className="platformLimitNumberHead">满载率</TableHead>
|
||||
<TableHead className="platformLimitMetricHead platformLimitNumberHead" title="正在执行 / 并发上限 / 排队任务">
|
||||
<span>并发</span>
|
||||
<small>正在执行 / 并发 / 排队</small>
|
||||
@@ -60,7 +133,6 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
|
||||
<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);
|
||||
@@ -78,16 +150,17 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
|
||||
<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">{platformPriorityCell(status, platform, props.onAdjustPriority)}</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>
|
||||
<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>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
@@ -101,6 +174,176 @@ function platformDisplayName(platform: IntegrationPlatform) {
|
||||
return platform.internalName?.trim() || platform.name;
|
||||
}
|
||||
|
||||
function platformPriorityCell(
|
||||
status: ModelRateLimitStatus,
|
||||
platform: IntegrationPlatform | undefined,
|
||||
onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void,
|
||||
) {
|
||||
const records = status.recentPriorityDemotions ?? [];
|
||||
const content = <PriorityDemotionPopover records={records} />;
|
||||
return (
|
||||
<span className="platformPriorityCell">
|
||||
<AntPopover
|
||||
align={{ offset: [0, 8] }}
|
||||
content={content}
|
||||
overlayClassName="priorityDemotionAntPopover"
|
||||
placement="bottomLeft"
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<button className="priorityDemotionTrigger" type="button" aria-label={priorityDemotionAriaLabel(status, platform)}>
|
||||
<span className="rateMetricCell">
|
||||
<strong>{formatPriority(platformEffectivePriority(status, platform))}</strong>
|
||||
<small>{platformPrioritySubtitle(status, platform, records.length)}</small>
|
||||
</span>
|
||||
</button>
|
||||
</AntPopover>
|
||||
<Button className="priorityAdjustButton" type="button" variant="outline" size="xs" onClick={() => onAdjustPriority(status, platform)}>
|
||||
<SlidersHorizontal size={13} />
|
||||
调整
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformPriorityDialog(props: {
|
||||
dialog: PriorityDialogState | null;
|
||||
error: string;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onReset: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onValueChange: (value: string) => void;
|
||||
}) {
|
||||
const dialog = props.dialog;
|
||||
const platform = dialog?.platform;
|
||||
const status = dialog?.status;
|
||||
return (
|
||||
<FormDialog
|
||||
bodyClassName="platformPriorityDialogBody"
|
||||
className="platformPriorityDialog"
|
||||
closeLabel="关闭"
|
||||
eyebrow="Runtime Priority"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" disabled={props.saving} onClick={props.onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" disabled={props.saving || !dialog} onClick={props.onReset}>
|
||||
<RotateCcw size={15} />
|
||||
重置为静态优先级
|
||||
</Button>
|
||||
<Button type="submit" disabled={props.saving || !dialog}>
|
||||
<CheckCircle2 size={15} />
|
||||
保存动态优先级
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
open={Boolean(dialog)}
|
||||
title="调整平台动态优先级"
|
||||
onClose={props.onClose}
|
||||
onSubmit={props.onSubmit}
|
||||
>
|
||||
<div className="platformPriorityDialogSummary">
|
||||
<span>
|
||||
<strong>{platform ? platformDisplayName(platform) : status?.platformName}</strong>
|
||||
<small>{status?.provider}</small>
|
||||
</span>
|
||||
<span>
|
||||
<strong>{formatPriority(platformEffectivePriority(status, platform))}</strong>
|
||||
<small>当前生效</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="platformPriorityDialogMetrics">
|
||||
<span>静态 {formatPriority(platformStaticPriority(status, platform))}</span>
|
||||
<span>动态 {formatPriority(platformDynamicPriority(status, platform))}</span>
|
||||
</div>
|
||||
<Label>
|
||||
动态优先级
|
||||
<Input
|
||||
value={dialog?.value ?? ''}
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
placeholder="例如 1511"
|
||||
onChange={(event) => props.onValueChange(event.target.value)}
|
||||
/>
|
||||
</Label>
|
||||
{props.error && <p className="formMessage error">{props.error}</p>}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityDemotionPopover(props: { records: PriorityDemotionRecord[] }) {
|
||||
if (!props.records.length) {
|
||||
return (
|
||||
<span className="priorityDemotionPopover" role="tooltip">
|
||||
<span className="priorityDemotionEmpty">暂无优先级降级记录</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="priorityDemotionPopover" role="tooltip">
|
||||
<span className="priorityDemotionHeader">
|
||||
<History size={14} />
|
||||
<strong>最近 10 条优先级降级</strong>
|
||||
</span>
|
||||
{props.records.map((record) => (
|
||||
<span key={record.id} className="priorityDemotionItem">
|
||||
<span className="priorityDemotionItemHeader">
|
||||
<strong>{priorityDemotionReasonText(record)}</strong>
|
||||
{priorityIsSet(record.dynamicPriority) ? <Badge variant="secondary">队尾 {formatPriority(record.dynamicPriority)}</Badge> : null}
|
||||
</span>
|
||||
<small>{priorityDemotionMetaText(record)}</small>
|
||||
{record.errorMessage && <span className="priorityDemotionError">{record.errorMessage}</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function platformPrioritySubtitle(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined, demotionCount: number) {
|
||||
const staticPriority = platformStaticPriority(status, platform);
|
||||
const dynamicPriority = platformDynamicPriority(status, platform);
|
||||
const base = priorityIsSet(dynamicPriority) ? `静态 ${formatPriority(staticPriority)} · 动态 ${formatPriority(dynamicPriority)}` : `静态 ${formatPriority(staticPriority)}`;
|
||||
return demotionCount ? `${base} · 降级 ${demotionCount}` : base;
|
||||
}
|
||||
|
||||
function priorityDemotionAriaLabel(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) {
|
||||
const count = status.recentPriorityDemotions?.length ?? 0;
|
||||
return `平台优先级 ${formatPriority(platformEffectivePriority(status, platform))},最近 ${count} 条优先级降级记录`;
|
||||
}
|
||||
|
||||
function priorityDemotionReasonText(record: PriorityDemotionRecord) {
|
||||
const category = record.category ? `错误分类 ${record.category}` : '';
|
||||
const code = record.errorCode ? `错误 ${record.errorCode}` : '';
|
||||
const statusCode = record.statusCode ? `状态码 ${record.statusCode}` : '';
|
||||
return [priorityDemotionReasonLabel(record.reason), statusCode, code, category].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function priorityDemotionMetaText(record: PriorityDemotionRecord) {
|
||||
const policy = priorityDemotionPolicyText(record);
|
||||
const values = [
|
||||
formatDateTime(record.createdAt),
|
||||
shortId(record.taskId) ? `任务 ${shortId(record.taskId)}` : '',
|
||||
policy,
|
||||
].filter(Boolean);
|
||||
return values.join(' · ') || '-';
|
||||
}
|
||||
|
||||
function priorityDemotionPolicyText(record: PriorityDemotionRecord) {
|
||||
const policyPath = [record.policySource || record.policy, record.policyRule].filter(Boolean).join('.');
|
||||
if (!policyPath) return '';
|
||||
return record.matchedValue ? `策略 ${policyPath}=${record.matchedValue}` : `策略 ${policyPath}`;
|
||||
}
|
||||
|
||||
function priorityDemotionReasonLabel(reason: string | undefined) {
|
||||
const labels: Record<string, string> = {
|
||||
priority_demote_policy: '命中优先级降级规则',
|
||||
hard_stop_policy: '命中硬拒绝规则',
|
||||
runner_policy_disabled: '全局调度策略停用',
|
||||
};
|
||||
return reason ? labels[reason] ?? reason : '优先级降级';
|
||||
}
|
||||
|
||||
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 (
|
||||
@@ -132,6 +375,19 @@ function formatTimeOfDay(timestamp: number | null) {
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | undefined) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
const pad = (item: number) => String(item).padStart(2, '0');
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function shortId(value: string | undefined) {
|
||||
if (!value) return '';
|
||||
return value.length > 8 ? value.slice(0, 8) : value;
|
||||
}
|
||||
|
||||
function modelRuntimeStatusCell(status: ModelRateLimitStatus, now: number) {
|
||||
const modelCooldownMs = cooldownRemainingMs(status.modelCooldownUntil, now);
|
||||
const platformCooldownMs = cooldownRemainingMs(status.platformCooldownUntil, now);
|
||||
@@ -184,6 +440,53 @@ function formatLimit(value: number) {
|
||||
return trimNumber(value);
|
||||
}
|
||||
|
||||
function platformEffectivePriority(status: ModelRateLimitStatus | undefined, platform: IntegrationPlatform | undefined) {
|
||||
return firstPriority(
|
||||
status?.platformEffectivePriority,
|
||||
platform?.effectivePriority,
|
||||
status?.platformDynamicPriority,
|
||||
platform?.dynamicPriority,
|
||||
status?.platformPriority,
|
||||
platform?.priority,
|
||||
);
|
||||
}
|
||||
|
||||
function platformStaticPriority(status: ModelRateLimitStatus | undefined, platform: IntegrationPlatform | undefined) {
|
||||
return firstPriority(status?.platformPriority, platform?.priority);
|
||||
}
|
||||
|
||||
function platformDynamicPriority(status: ModelRateLimitStatus | undefined, platform: IntegrationPlatform | undefined) {
|
||||
return firstPriority(
|
||||
status?.platformDynamicPriority,
|
||||
platform?.dynamicPriority,
|
||||
status?.platformEffectivePriority,
|
||||
platform?.effectivePriority,
|
||||
status?.platformPriority,
|
||||
platform?.priority,
|
||||
);
|
||||
}
|
||||
|
||||
function firstPriority(...values: Array<number | undefined>) {
|
||||
return values.find(priorityIsSet);
|
||||
}
|
||||
|
||||
function priorityIsSet(value: number | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function formatPriority(value: number | undefined) {
|
||||
if (!Number.isFinite(value)) return '-';
|
||||
return String(Math.trunc(value ?? 0));
|
||||
}
|
||||
|
||||
function parsePriorityInput(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isSafeInteger(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function trimNumber(value: number) {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ type RunnerPolicyForm = {
|
||||
hardStopStatusCodes: string[];
|
||||
hardStopKeywords: string[];
|
||||
priorityDemoteEnabled: boolean;
|
||||
priorityDemoteStep: string;
|
||||
priorityDemoteCategories: string[];
|
||||
priorityDemoteCodes: string[];
|
||||
priorityDemoteStatusCodes: string[];
|
||||
@@ -406,11 +405,7 @@ function RunnerPolicyEditor(props: {
|
||||
{activeStrategy === 'priorityDemote' && (
|
||||
<div className="runtimePolicyRows runnerPolicyDetailRows">
|
||||
<Toggle checked={props.form.priorityDemoteEnabled} label="启用优先级降级" onChange={(checked) => patch({ priorityDemoteEnabled: checked })} />
|
||||
<Label>
|
||||
降级步长
|
||||
<Input value={props.form.priorityDemoteStep} inputMode="numeric" onChange={(event) => patch({ priorityDemoteStep: event.target.value })} />
|
||||
<span className="runtimeFieldHint">命中降级规则后,失败平台动态优先级向后调整的幅度,默认 100。</span>
|
||||
</Label>
|
||||
<span className="runtimeFieldHint spanTwo">命中降级规则后,失败平台会自动调整到当前所有平台的优先级队尾。</span>
|
||||
<KeywordField label="降级分类" value={props.form.priorityDemoteCategories} onChange={(value) => patch({ priorityDemoteCategories: value })} />
|
||||
<KeywordField label="降级错误码" value={props.form.priorityDemoteCodes} onChange={(value) => patch({ priorityDemoteCodes: value })} />
|
||||
<KeywordField label="降级状态码" value={props.form.priorityDemoteStatusCodes} onChange={(value) => patch({ priorityDemoteStatusCodes: value })} />
|
||||
@@ -478,7 +473,6 @@ function runnerPolicyToForm(policy: GatewayRunnerPolicy | null): RunnerPolicyFor
|
||||
hardStopStatusCodes: tagsFromValue(hardStop.statusCodes ?? []),
|
||||
hardStopKeywords: tagsFromValue(hardStop.keywords ?? ['invalid_parameter', 'missing required', 'bad request', 'insufficient balance']),
|
||||
priorityDemoteEnabled: readBool(priorityDemote.enabled, true),
|
||||
priorityDemoteStep: String(readNumber(priorityDemote.demoteStep, 100)),
|
||||
priorityDemoteCategories: tagsFromValue(priorityDemote.categories ?? ['network', 'timeout', 'stream_error', 'rate_limit', 'provider_5xx', 'provider_overloaded']),
|
||||
priorityDemoteCodes: tagsFromValue(priorityDemote.codes ?? ['network', 'timeout', 'stream_read_error', 'rate_limit', 'server_error', 'overloaded']),
|
||||
priorityDemoteStatusCodes: tagsFromValue(priorityDemote.statusCodes ?? [408, 429, 500, 502, 503, 504]),
|
||||
@@ -516,7 +510,6 @@ function runnerFormToPayload(form: RunnerPolicyForm): GatewayRunnerPolicyUpsertR
|
||||
},
|
||||
priorityDemotePolicy: {
|
||||
enabled: form.priorityDemoteEnabled,
|
||||
demoteStep: positiveInt(form.priorityDemoteStep, 100),
|
||||
categories: cleanTags(form.priorityDemoteCategories),
|
||||
codes: cleanTags(form.priorityDemoteCodes),
|
||||
statusCodes: parseNumberTags(form.priorityDemoteStatusCodes),
|
||||
|
||||
Reference in New Issue
Block a user