From 8aa8947e1aefcdab08db40ea67e425fca2aee097 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 3 Jul 2026 22:28:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E9=98=9F=E5=88=97=E6=94=AF=E6=8C=81=E6=8B=96=E6=8B=BD=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E4=BC=98=E5=85=88=E7=BA=A7=E4=B8=94=E4=B8=8D=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E5=BD=93=E6=97=A5=E9=85=8D=E9=A2=9D;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- src/coding/proxy/server/dashboard.py | 128 ++++++++++++++++++++++++++- src/coding/proxy/server/routes.py | 48 ++++++++++ tests/test_app_routes.py | 122 +++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 1 deletion(-) diff --git a/src/coding/proxy/server/dashboard.py b/src/coding/proxy/server/dashboard.py index 75c99da..6ce7f2c 100644 --- a/src/coding/proxy/server/dashboard.py +++ b/src/coding/proxy/server/dashboard.py @@ -411,6 +411,20 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: height: 100%; border-radius: 2px; transition: width .6s cubic-bezier(.4,0,.2,1); } + /* ── 供应商状态:拖拽排序 ── */ + .vendor-item[draggable="true"] { cursor: grab; } + .vendor-item[draggable="true"]:active { cursor: grabbing; } + .vendor-item.dragging { opacity: .4; } + .drag-handle { + display: flex; align-items: center; flex-shrink: 0; + color: var(--text-tertiary); font-size: 15px; line-height: 1; + cursor: grab; user-select: none; padding: 0 2px; + opacity: 0; transition: opacity .2s ease; + } + .vendor-item:hover .drag-handle { opacity: .55; } + .drag-handle:hover { opacity: 1 !important; color: var(--text-secondary); } + .vendor-item.drag-over-before { box-shadow: inset 0 2px 0 0 var(--accent-blue); } + .vendor-item.drag-over-after { box-shadow: inset 0 -2px 0 0 var(--accent-blue); } /* ── 故障转移表 ── */ .ft-table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; } @@ -1332,12 +1346,15 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: } function updateVendorStatus(status) { + // 拖拽进行中 / 重排序 POST 未决时跳过重渲染,避免抢占正在操作的 DOM + if (_tierDrag.active || _tierDrag.inFlight) return; const tiers = status.tiers || []; const list = document.getElementById('vendor-list'); if (!tiers.length) { list.innerHTML = '
🔌
无供应商数据
'; return; } + const draggable = tiers.length >= 2; list.innerHTML = tiers.map(tier => { const cb = tier.circuit_breaker || {}; const cbClass = cbStateClass(cb.state); @@ -1351,8 +1368,12 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: const rlInfo = tier.rate_limit || {}; const rlHtml = rlInfo.limited ? `限速中` : ''; - return `
+ const dragAttrs = ` data-vendor="${tier.name}"` + (draggable ? ` draggable="true"` : ''); + const handle = draggable ? `
` : ''; + + return `
+ ${handle}
${initial}
${tier.name}
@@ -1365,6 +1386,110 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: }).join(''); } +// ── 供应商状态:拖拽调整优先级(运行时重排,不重置配额) ──── +const _tierDrag = { active: false, srcName: null, inFlight: false }; + +function _tierDragClearIndicators() { + document.querySelectorAll('#vendor-list .drag-over-before, #vendor-list .drag-over-after') + .forEach(function(el) { el.classList.remove('drag-over-before', 'drag-over-after'); }); +} + +function _tierDragPosition(item, clientY) { + // 鼠标相对目标行中点的位置 → 插入到上方或下方 + const rect = item.getBoundingClientRect(); + return clientY < rect.top + rect.height / 2 ? 'before' : 'after'; +} + +function initTierDrag() { + // 事件委托绑定在静态容器 #vendor-list 上一次;子节点重渲染后仍生效 + const list = document.getElementById('vendor-list'); + if (!list || list.dataset.dndBound === '1') return; + list.dataset.dndBound = '1'; + + list.addEventListener('dragstart', function(e) { + const item = e.target && e.target.closest && e.target.closest('.vendor-item'); + if (!item || !item.draggable) return; + _tierDrag.active = true; + _tierDrag.srcName = item.dataset.vendor; + item.classList.add('dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', _tierDrag.srcName || ''); } catch (_) {} + } + }); + + list.addEventListener('dragover', function(e) { + if (!_tierDrag.active) return; + e.preventDefault(); // 允许 drop(无论是否命中某一行) + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + const item = e.target && e.target.closest && e.target.closest('.vendor-item'); + _tierDragClearIndicators(); + if (!item || item.dataset.vendor === _tierDrag.srcName) return; + item.classList.add(_tierDragPosition(item, e.clientY) === 'before' ? 'drag-over-before' : 'drag-over-after'); + }); + + list.addEventListener('drop', function(e) { + if (!_tierDrag.active) return; + e.preventDefault(); + const listEl = document.getElementById('vendor-list'); + const dragged = listEl.querySelector('.vendor-item.dragging'); + if (!dragged) return; + let target = e.target && e.target.closest && e.target.closest('.vendor-item'); + if (!target) { + // 落在容器空白处 → 移到末尾 + if (listEl.lastElementChild !== dragged) listEl.appendChild(dragged); + } else if (target !== dragged) { + const ref = _tierDragPosition(target, e.clientY) === 'before' ? target : target.nextElementSibling; + if (ref !== dragged) listEl.insertBefore(dragged, ref); + } + _tierDragClearIndicators(); + const order = Array.from(listEl.children) + .filter(function(el) { return el.classList.contains('vendor-item'); }) + .map(function(el) { return el.dataset.vendor; }) + .filter(Boolean); + persistTierOrder(order); + }); + + list.addEventListener('dragend', function() { + _tierDrag.active = false; + _tierDrag.srcName = null; + _tierDragClearIndicators(); + document.querySelectorAll('#vendor-list .dragging') + .forEach(function(el) { el.classList.remove('dragging'); }); + }); +} + +function persistTierOrder(names) { + if (_tierDrag.inFlight) return; // 防并发:上一次未决则忽略 + if (!Array.isArray(names) || names.length < 2) return; + _tierDrag.inFlight = true; + fetch('/api/tier-order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vendors: names }) + }).then(function(res) { + _tierDrag.inFlight = false; // 先清守卫,允许随后的 refreshOverview 重渲染 + if (res.ok) { + // 服务端已更新顺序;刷新以同步请求趋势图等的排序 + if (!_tierDrag.active) refreshOverview().catch(function() {}); + } else { + console.error('tier-order rejected:', res.status); + _tierRevertToList(); + } + }).catch(function(e) { + _tierDrag.inFlight = false; + console.error('tier-order failed:', e); + _tierRevertToList(); + }); +} + +function _tierRevertToList() { + // 回滚到服务端真实顺序(PUT 失败 → 服务端顺序未变) + fetchJSON('/api/status').then(function(status) { + updateVendorStatus(status); + }).catch(function() {}); +} + // ── Model Calling 实时状态 ──────────────────────────────── function updateModelCalling(status) { var wrap = document.getElementById('model-calling-wrap'); @@ -2200,6 +2325,7 @@ def line(x0: float, y0: float, x1: float, y1: float) -> None: fetchJSON('/api/dashboard/summary?days=7').then(function(s) { if (s && s.version) document.getElementById('version-badge').textContent = 'v' + s.version; }).catch(function(){}); + initTierDrag(); // 绑定供应商列表拖拽(事件委托,幂等) refresh(); // 仅加载初始页签的数据 setInterval(refresh, 600000); // 每 10 分钟刷新当前页签 if (initial === 'overview') startModelCallingPoll(); diff --git a/src/coding/proxy/server/routes.py b/src/coding/proxy/server/routes.py index 8b47fd0..6b91651 100644 --- a/src/coding/proxy/server/routes.py +++ b/src/coding/proxy/server/routes.py @@ -386,6 +386,53 @@ async def reset_circuit(request: Request) -> Response: ) +def register_tier_order_route(app: Any, router: Any) -> None: + """注册运行时 N-tier 链路顺序调整路由(纯重排序,不重置熔断器/配额守卫).""" + + @app.put("/api/tier-order") + async def update_tier_order(request: Request) -> Response: + """替换整个 N-tier 链路顺序. + + JSON body: ``{"vendors": ["v1", "v2", ...]}``(需覆盖所有当前启用的 vendor)。 + 与 ``/api/reset`` 的重排序语义一致,但**不会**重置熔断器/配额守卫/rate limit, + 以保留当日用量统计(供 Web 拖拽调整优先级使用)。 + """ + try: + body = await request.json() + except Exception: + return json_error_response( + 400, error_type="invalid_request_error", message="body must be JSON" + ) + if not isinstance(body, dict): + return json_error_response( + 400, + error_type="invalid_request_error", + message="body must be a JSON object", + ) + raw = body.get("vendors") + if not isinstance(raw, list) or not raw: + return json_error_response( + 400, + error_type="invalid_request_error", + message="requires non-empty 'vendors' list", + ) + vendor_names = [str(v) for v in raw] + try: + router.reorder_tiers(vendor_names) + except ValueError as exc: + return json_error_response( + 400, error_type="invalid_request_error", message=str(exc) + ) + return Response( + content=json.dumps( + {"ok": True, "tier_order": router.get_vendor_names()}, + ensure_ascii=False, + ).encode(), + status_code=200, + media_type="application/json", + ) + + def register_session_vendor_routes(app: Any, router: Any) -> None: """注册 Session-Vendor 运行时绑定路由.""" @@ -518,6 +565,7 @@ def register_all_routes( register_concurrency_route(app, router) register_copilot_routes(app, router) register_admin_routes(app, router) + register_tier_order_route(app, router) register_session_vendor_routes(app, router) if reauth_coordinator: register_reauth_routes(app, reauth_coordinator) diff --git a/tests/test_app_routes.py b/tests/test_app_routes.py index 39aabe6..5e61a27 100644 --- a/tests/test_app_routes.py +++ b/tests/test_app_routes.py @@ -1091,3 +1091,125 @@ def test_reorder_tiers_shared_reference(): # Executor 的列表也改变了(因为是同一个对象) assert [t.name for t in executor_tiers] == ["c", "a", "b"] assert router.get_vendor_names() == ["c", "a", "b"] + + +# ── /api/tier-order 纯重排序测试(不重置配额/熔断器/rate limit)────────────── + + +def test_tier_order_reorder_success(): + """PUT /api/tier-order → 精确替换链路顺序,返回最新 tier_order.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", json={"vendors": ["copilot", "anthropic", "zhipu"]} + ) + assert resp.status_code == 200 + data = resp.json() + assert data["ok"] is True + assert data["tier_order"] == ["copilot", "anthropic", "zhipu"] + assert app.state.router.get_vendor_names() == ["copilot", "anthropic", "zhipu"] + + +def test_tier_order_unknown_vendor_returns_400(): + """未知 vendor 名称 → 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", + json={"vendors": ["nonexist", "anthropic", "zhipu", "copilot"]}, + ) + assert resp.status_code == 400 + assert "未知 vendor" in resp.json()["error"]["message"] + + +def test_tier_order_duplicate_returns_400(): + """重复 vendor 名称 → 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", + json={"vendors": ["anthropic", "anthropic", "zhipu", "copilot"]}, + ) + assert resp.status_code == 400 + assert "重复" in resp.json()["error"]["message"] + + +def test_tier_order_incomplete_returns_400(): + """不完整的 vendor 列表(缺少现有 tier)→ 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + resp = client.put("/api/tier-order", json={"vendors": ["anthropic", "zhipu"]}) + assert resp.status_code == 400 + assert "缺少 vendor" in resp.json()["error"]["message"] + + +def test_tier_order_invalid_body_returns_400(): + """非法 body(非 JSON / 缺 vendors / 非列表 / 空列表)→ 400.""" + app = _make_reorder_app() + with TestClient(app) as client: + # 非 JSON + resp = client.put( + "/api/tier-order", + content=b"not-json", + headers={"content-type": "application/json"}, + ) + assert resp.status_code == 400 + # body 非 dict + resp = client.put("/api/tier-order", json=["anthropic", "zhipu", "copilot"]) + assert resp.status_code == 400 + # 缺 vendors + resp = client.put("/api/tier-order", json={"foo": "bar"}) + assert resp.status_code == 400 + # vendors 非列表 + resp = client.put("/api/tier-order", json={"vendors": "zhipu"}) + assert resp.status_code == 400 + # vendors 空列表 + resp = client.put("/api/tier-order", json={"vendors": []}) + assert resp.status_code == 400 + + +def test_tier_order_preserves_quota_guard(): + """核心:重排序不重置配额守卫的滑动窗口用量(保留当日统计).""" + app = _make_reorder_app() + router = app.state.router + # 选用首个 tier,手动启用其配额守卫并写入用量 + tier = router.tiers[0] + qg = tier.quota_guard + assert qg is not None + qg._enabled = True + qg._budget = 100_000 + qg.record_usage(40_000) + before = qg.get_info() + assert before["window_usage_tokens"] == 40_000 + assert before["usage_percent"] == 40.0 + + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", json={"vendors": ["zhipu", "copilot", "anthropic"]} + ) + assert resp.status_code == 200 + + # reorder_tiers 仅重排引用、不重建对象 → 同一 QuotaGuard 实例用量保持 + after = qg.get_info() + assert after["window_usage_tokens"] == 40_000 + assert after["usage_percent"] == 40.0 + + +def test_tier_order_preserves_circuit_breaker_and_rate_limit(): + """重排序不重置熔断器与 rate limit(与 /api/reset 行为相反).""" + app = _make_reorder_app() + router = app.state.router + target = router.tiers[0] + target.record_failure(retry_after_seconds=300) + target._rate_limit_deadline = 999_999.0 + assert not target.can_execute() + + with TestClient(app) as client: + resp = client.put( + "/api/tier-order", json={"vendors": ["zhipu", "anthropic", "copilot"]} + ) + assert resp.status_code == 200 + + same = next(t for t in router.tiers if t.name == target.name) + assert not same.can_execute() # 仍熔断 + assert same.is_rate_limited # 仍限速