Skip to content

Commit 8aa8947

Browse files
committed
feat(dashboard): 供应商队列支持拖拽调整优先级且不重置当日配额;
- 新增 PUT /api/tier-order 纯重排序端点:复用 router.reorder_tiers,不触碰熔断器/配额守卫/rate limit,与 /api/reset 解耦 - 供应商状态列表基于 HTML5 DnD 在 #vendor-list 上做事件委托,渲染拖拽手柄与 draggable 属性 - 拖拽完成后乐观重排 DOM 并 PUT 新顺序;失败回滚至服务端真实顺序;拖拽/POST 未决期间跳过重渲染 - 新增 7 个 /api/tier-order 测试,覆盖成功、校验失败及配额守卫/熔断器保留断言 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang<threefish.ai@gmail.com>
1 parent 7f62f01 commit 8aa8947

3 files changed

Lines changed: 297 additions & 1 deletion

File tree

src/coding/proxy/server/dashboard.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,20 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
411411
height: 100%; border-radius: 2px;
412412
transition: width .6s cubic-bezier(.4,0,.2,1);
413413
}
414+
/* ── 供应商状态:拖拽排序 ── */
415+
.vendor-item[draggable="true"] { cursor: grab; }
416+
.vendor-item[draggable="true"]:active { cursor: grabbing; }
417+
.vendor-item.dragging { opacity: .4; }
418+
.drag-handle {
419+
display: flex; align-items: center; flex-shrink: 0;
420+
color: var(--text-tertiary); font-size: 15px; line-height: 1;
421+
cursor: grab; user-select: none; padding: 0 2px;
422+
opacity: 0; transition: opacity .2s ease;
423+
}
424+
.vendor-item:hover .drag-handle { opacity: .55; }
425+
.drag-handle:hover { opacity: 1 !important; color: var(--text-secondary); }
426+
.vendor-item.drag-over-before { box-shadow: inset 0 2px 0 0 var(--accent-blue); }
427+
.vendor-item.drag-over-after { box-shadow: inset 0 -2px 0 0 var(--accent-blue); }
414428
/* ── 故障转移表 ── */
415429
.ft-table-wrap { overflow-x: auto; }
416430
table { width: 100%; border-collapse: collapse; }
@@ -1332,12 +1346,15 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13321346
}
13331347
13341348
function updateVendorStatus(status) {
1349+
// 拖拽进行中 / 重排序 POST 未决时跳过重渲染,避免抢占正在操作的 DOM
1350+
if (_tierDrag.active || _tierDrag.inFlight) return;
13351351
const tiers = status.tiers || [];
13361352
const list = document.getElementById('vendor-list');
13371353
if (!tiers.length) {
13381354
list.innerHTML = '<div class="empty"><div class="empty-icon">🔌</div>无供应商数据</div>';
13391355
return;
13401356
}
1357+
const draggable = tiers.length >= 2;
13411358
list.innerHTML = tiers.map(tier => {
13421359
const cb = tier.circuit_breaker || {};
13431360
const cbClass = cbStateClass(cb.state);
@@ -1351,8 +1368,12 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13511368
const rlInfo = tier.rate_limit || {};
13521369
const rlHtml = rlInfo.limited ? `<span class="status-badge sb-warn">限速中</span>` : '';
13531370
1354-
return `<div class="vendor-item">
1371+
const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : '');
1372+
const handle = draggable ? `<div class="drag-handle" title="拖拽调整优先级">⠿</div>` : '';
1373+
1374+
return `<div class="vendor-item"${dragAttrs}>
13551375
<div class="vendor-info">
1376+
${handle}
13561377
<div class="vendor-avatar">${initial}</div>
13571378
<span class="vendor-name">${tier.name}</span>
13581379
</div>
@@ -1365,6 +1386,110 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
13651386
}).join('');
13661387
}
13671388
1389+
// ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ────
1390+
const _tierDrag = { active: false, srcName: null, inFlight: false };
1391+
1392+
function _tierDragClearIndicators() {
1393+
document.querySelectorAll('#vendor-list .drag-over-before, #vendor-list .drag-over-after')
1394+
.forEach(function(el) { el.classList.remove('drag-over-before', 'drag-over-after'); });
1395+
}
1396+
1397+
function _tierDragPosition(item, clientY) {
1398+
// 鼠标相对目标行中点的位置 → 插入到上方或下方
1399+
const rect = item.getBoundingClientRect();
1400+
return clientY < rect.top + rect.height / 2 ? 'before' : 'after';
1401+
}
1402+
1403+
function initTierDrag() {
1404+
// 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效
1405+
const list = document.getElementById('vendor-list');
1406+
if (!list || list.dataset.dndBound === '1') return;
1407+
list.dataset.dndBound = '1';
1408+
1409+
list.addEventListener('dragstart', function(e) {
1410+
const item = e.target && e.target.closest && e.target.closest('.vendor-item');
1411+
if (!item || !item.draggable) return;
1412+
_tierDrag.active = true;
1413+
_tierDrag.srcName = item.dataset.vendor;
1414+
item.classList.add('dragging');
1415+
if (e.dataTransfer) {
1416+
e.dataTransfer.effectAllowed = 'move';
1417+
try { e.dataTransfer.setData('text/plain', _tierDrag.srcName || ''); } catch (_) {}
1418+
}
1419+
});
1420+
1421+
list.addEventListener('dragover', function(e) {
1422+
if (!_tierDrag.active) return;
1423+
e.preventDefault(); // 允许 drop(无论是否命中某一行)
1424+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
1425+
const item = e.target && e.target.closest && e.target.closest('.vendor-item');
1426+
_tierDragClearIndicators();
1427+
if (!item || item.dataset.vendor === _tierDrag.srcName) return;
1428+
item.classList.add(_tierDragPosition(item, e.clientY) === 'before' ? 'drag-over-before' : 'drag-over-after');
1429+
});
1430+
1431+
list.addEventListener('drop', function(e) {
1432+
if (!_tierDrag.active) return;
1433+
e.preventDefault();
1434+
const listEl = document.getElementById('vendor-list');
1435+
const dragged = listEl.querySelector('.vendor-item.dragging');
1436+
if (!dragged) return;
1437+
let target = e.target && e.target.closest && e.target.closest('.vendor-item');
1438+
if (!target) {
1439+
// 落在容器空白处 → 移到末尾
1440+
if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged);
1441+
} else if (target !== dragged) {
1442+
const ref = _tierDragPosition(target, e.clientY) === 'before' ? target : target.nextElementSibling;
1443+
if (ref !== dragged) listEl.insertBefore(dragged, ref);
1444+
}
1445+
_tierDragClearIndicators();
1446+
const order = Array.from(listEl.children)
1447+
.filter(function(el) { return el.classList.contains('vendor-item'); })
1448+
.map(function(el) { return el.dataset.vendor; })
1449+
.filter(Boolean);
1450+
persistTierOrder(order);
1451+
});
1452+
1453+
list.addEventListener('dragend', function() {
1454+
_tierDrag.active = false;
1455+
_tierDrag.srcName = null;
1456+
_tierDragClearIndicators();
1457+
document.querySelectorAll('#vendor-list .dragging')
1458+
.forEach(function(el) { el.classList.remove('dragging'); });
1459+
});
1460+
}
1461+
1462+
function persistTierOrder(names) {
1463+
if (_tierDrag.inFlight) return; // 防并发:上一次未决则忽略
1464+
if (!Array.isArray(names) || names.length < 2) return;
1465+
_tierDrag.inFlight = true;
1466+
fetch('/api/tier-order', {
1467+
method: 'PUT',
1468+
headers: { 'Content-Type': 'application/json' },
1469+
body: JSON.stringify({ vendors: names })
1470+
}).then(function(res) {
1471+
_tierDrag.inFlight = false; // 先清守卫,允许随后的 refreshOverview 重渲染
1472+
if (res.ok) {
1473+
// 服务端已更新顺序;刷新以同步请求趋势图等的排序
1474+
if (!_tierDrag.active) refreshOverview().catch(function() {});
1475+
} else {
1476+
console.error('tier-order rejected:', res.status);
1477+
_tierRevertToList();
1478+
}
1479+
}).catch(function(e) {
1480+
_tierDrag.inFlight = false;
1481+
console.error('tier-order failed:', e);
1482+
_tierRevertToList();
1483+
});
1484+
}
1485+
1486+
function _tierRevertToList() {
1487+
// 回滚到服务端真实顺序(PUT 失败 → 服务端顺序未变)
1488+
fetchJSON('/api/status').then(function(status) {
1489+
updateVendorStatus(status);
1490+
}).catch(function() {});
1491+
}
1492+
13681493
// ── Model Calling 实时状态 ────────────────────────────────
13691494
function updateModelCalling(status) {
13701495
var wrap = document.getElementById('model-calling-wrap');
@@ -2200,6 +2325,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None:
22002325
fetchJSON('/api/dashboard/summary?days=7').then(function(s) {
22012326
if (s && s.version) document.getElementById('version-badge').textContent = 'v' + s.version;
22022327
}).catch(function(){});
2328+
initTierDrag(); // 绑定供应商列表拖拽(事件委托,幂等)
22032329
refresh(); // 仅加载初始页签的数据
22042330
setInterval(refresh, 600000); // 每 10 分钟刷新当前页签
22052331
if (initial === 'overview') startModelCallingPoll();

src/coding/proxy/server/routes.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,53 @@ async def reset_circuit(request: Request) -> Response:
386386
)
387387

388388

389+
def register_tier_order_route(app: Any, router: Any) -> None:
390+
"""注册运行时 N-tier 链路顺序调整路由(纯重排序,不重置熔断器/配额守卫)."""
391+
392+
@app.put("/api/tier-order")
393+
async def update_tier_order(request: Request) -> Response:
394+
"""替换整个 N-tier 链路顺序.
395+
396+
JSON body: ``{"vendors": ["v1", "v2", ...]}``(需覆盖所有当前启用的 vendor)。
397+
与 ``/api/reset`` 的重排序语义一致,但**不会**重置熔断器/配额守卫/rate limit,
398+
以保留当日用量统计(供 Web 拖拽调整优先级使用)。
399+
"""
400+
try:
401+
body = await request.json()
402+
except Exception:
403+
return json_error_response(
404+
400, error_type="invalid_request_error", message="body must be JSON"
405+
)
406+
if not isinstance(body, dict):
407+
return json_error_response(
408+
400,
409+
error_type="invalid_request_error",
410+
message="body must be a JSON object",
411+
)
412+
raw = body.get("vendors")
413+
if not isinstance(raw, list) or not raw:
414+
return json_error_response(
415+
400,
416+
error_type="invalid_request_error",
417+
message="requires non-empty 'vendors' list",
418+
)
419+
vendor_names = [str(v) for v in raw]
420+
try:
421+
router.reorder_tiers(vendor_names)
422+
except ValueError as exc:
423+
return json_error_response(
424+
400, error_type="invalid_request_error", message=str(exc)
425+
)
426+
return Response(
427+
content=json.dumps(
428+
{"ok": True, "tier_order": router.get_vendor_names()},
429+
ensure_ascii=False,
430+
).encode(),
431+
status_code=200,
432+
media_type="application/json",
433+
)
434+
435+
389436
def register_session_vendor_routes(app: Any, router: Any) -> None:
390437
"""注册 Session-Vendor 运行时绑定路由."""
391438

@@ -518,6 +565,7 @@ def register_all_routes(
518565
register_concurrency_route(app, router)
519566
register_copilot_routes(app, router)
520567
register_admin_routes(app, router)
568+
register_tier_order_route(app, router)
521569
register_session_vendor_routes(app, router)
522570
if reauth_coordinator:
523571
register_reauth_routes(app, reauth_coordinator)

tests/test_app_routes.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,3 +1091,125 @@ def test_reorder_tiers_shared_reference():
10911091
# Executor 的列表也改变了(因为是同一个对象)
10921092
assert [t.name for t in executor_tiers] == ["c", "a", "b"]
10931093
assert router.get_vendor_names() == ["c", "a", "b"]
1094+
1095+
1096+
# ── /api/tier-order 纯重排序测试(不重置配额/熔断器/rate limit)──────────────
1097+
1098+
1099+
def test_tier_order_reorder_success():
1100+
"""PUT /api/tier-order → 精确替换链路顺序,返回最新 tier_order."""
1101+
app = _make_reorder_app()
1102+
with TestClient(app) as client:
1103+
resp = client.put(
1104+
"/api/tier-order", json={"vendors": ["copilot", "anthropic", "zhipu"]}
1105+
)
1106+
assert resp.status_code == 200
1107+
data = resp.json()
1108+
assert data["ok"] is True
1109+
assert data["tier_order"] == ["copilot", "anthropic", "zhipu"]
1110+
assert app.state.router.get_vendor_names() == ["copilot", "anthropic", "zhipu"]
1111+
1112+
1113+
def test_tier_order_unknown_vendor_returns_400():
1114+
"""未知 vendor 名称 → 400."""
1115+
app = _make_reorder_app()
1116+
with TestClient(app) as client:
1117+
resp = client.put(
1118+
"/api/tier-order",
1119+
json={"vendors": ["nonexist", "anthropic", "zhipu", "copilot"]},
1120+
)
1121+
assert resp.status_code == 400
1122+
assert "未知 vendor" in resp.json()["error"]["message"]
1123+
1124+
1125+
def test_tier_order_duplicate_returns_400():
1126+
"""重复 vendor 名称 → 400."""
1127+
app = _make_reorder_app()
1128+
with TestClient(app) as client:
1129+
resp = client.put(
1130+
"/api/tier-order",
1131+
json={"vendors": ["anthropic", "anthropic", "zhipu", "copilot"]},
1132+
)
1133+
assert resp.status_code == 400
1134+
assert "重复" in resp.json()["error"]["message"]
1135+
1136+
1137+
def test_tier_order_incomplete_returns_400():
1138+
"""不完整的 vendor 列表(缺少现有 tier)→ 400."""
1139+
app = _make_reorder_app()
1140+
with TestClient(app) as client:
1141+
resp = client.put("/api/tier-order", json={"vendors": ["anthropic", "zhipu"]})
1142+
assert resp.status_code == 400
1143+
assert "缺少 vendor" in resp.json()["error"]["message"]
1144+
1145+
1146+
def test_tier_order_invalid_body_returns_400():
1147+
"""非法 body(非 JSON / 缺 vendors / 非列表 / 空列表)→ 400."""
1148+
app = _make_reorder_app()
1149+
with TestClient(app) as client:
1150+
# 非 JSON
1151+
resp = client.put(
1152+
"/api/tier-order",
1153+
content=b"not-json",
1154+
headers={"content-type": "application/json"},
1155+
)
1156+
assert resp.status_code == 400
1157+
# body 非 dict
1158+
resp = client.put("/api/tier-order", json=["anthropic", "zhipu", "copilot"])
1159+
assert resp.status_code == 400
1160+
# 缺 vendors
1161+
resp = client.put("/api/tier-order", json={"foo": "bar"})
1162+
assert resp.status_code == 400
1163+
# vendors 非列表
1164+
resp = client.put("/api/tier-order", json={"vendors": "zhipu"})
1165+
assert resp.status_code == 400
1166+
# vendors 空列表
1167+
resp = client.put("/api/tier-order", json={"vendors": []})
1168+
assert resp.status_code == 400
1169+
1170+
1171+
def test_tier_order_preserves_quota_guard():
1172+
"""核心:重排序不重置配额守卫的滑动窗口用量(保留当日统计)."""
1173+
app = _make_reorder_app()
1174+
router = app.state.router
1175+
# 选用首个 tier,手动启用其配额守卫并写入用量
1176+
tier = router.tiers[0]
1177+
qg = tier.quota_guard
1178+
assert qg is not None
1179+
qg._enabled = True
1180+
qg._budget = 100_000
1181+
qg.record_usage(40_000)
1182+
before = qg.get_info()
1183+
assert before["window_usage_tokens"] == 40_000
1184+
assert before["usage_percent"] == 40.0
1185+
1186+
with TestClient(app) as client:
1187+
resp = client.put(
1188+
"/api/tier-order", json={"vendors": ["zhipu", "copilot", "anthropic"]}
1189+
)
1190+
assert resp.status_code == 200
1191+
1192+
# reorder_tiers 仅重排引用、不重建对象 → 同一 QuotaGuard 实例用量保持
1193+
after = qg.get_info()
1194+
assert after["window_usage_tokens"] == 40_000
1195+
assert after["usage_percent"] == 40.0
1196+
1197+
1198+
def test_tier_order_preserves_circuit_breaker_and_rate_limit():
1199+
"""重排序不重置熔断器与 rate limit(与 /api/reset 行为相反)."""
1200+
app = _make_reorder_app()
1201+
router = app.state.router
1202+
target = router.tiers[0]
1203+
target.record_failure(retry_after_seconds=300)
1204+
target._rate_limit_deadline = 999_999.0
1205+
assert not target.can_execute()
1206+
1207+
with TestClient(app) as client:
1208+
resp = client.put(
1209+
"/api/tier-order", json={"vendors": ["zhipu", "anthropic", "copilot"]}
1210+
)
1211+
assert resp.status_code == 200
1212+
1213+
same = next(t for t in router.tiers if t.name == target.name)
1214+
assert not same.can_execute() # 仍熔断
1215+
assert same.is_rate_limited # 仍限速

0 commit comments

Comments
 (0)