perf(admin): reduce tool health polling load
This commit is contained in:
parent
2ff648afe6
commit
dbaa9faa6e
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 管理后台会异步汇总工具健康大数,并避免短时间内重复请求;多人同时查看或自动刷新时不再反复扫描历史记录。
|
||||
|
||||
- 单用户可同时运行的重型任务由 2 个提升到 3 个;任务等待执行容量时,对话会直接说明是当前用户、整机或宿主内存限制,获得槽位后自动继续。
|
||||
|
||||
- 管理员现在可在管理后台安全录入、测试和更换模型、媒体、检索及语音服务凭据,并查看来源、脱敏尾号和可用状态;数据库凭据可随时删除并回退原有环境配置。DeepSeek 余额低于 30 元、额度耗尽或认证失败时会主动提醒。
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-09-03(Unreleased:管理后台紧凑总览与分组工作区)
|
||||
最后更新:2026-09-03(Unreleased:工具健康按需刷新与短时缓存)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,6 +20,8 @@
|
|||
---
|
||||
## 已完成关键能力
|
||||
|
||||
- **09-03 / Unreleased / 工具健康监控请求收敛**:Admin 首次进入时异步汇总工具健康大数,之后固定按 90 秒节流刷新并对进行中请求单飞,不再每 10 秒重复扫描近 7 天记录。服务端按查询参数增加 90 秒进程内单飞缓存,多页面或多管理员并发时共享一次扫描,日巡检继续直读不受缓存影响。未新增表、字段、migration 或外部配置,结构化普通工具失败事件因双写会与 messages 历史口径重复而留待独立迁移。
|
||||
|
||||
- **09-03 / Unreleased / 管理后台紧凑总览与分组工作区**:Admin 由纵向长页改为顶部常驻的两级总览和“运行、用量、用户、资源、集成、质量”六个页签,四项运行状态使用主卡、四项运营指标收进紧凑指标栏;点击总览可切换页签并精确定位同口径详情,任务、容量、用户活跃、Token/成本和存储均补齐详情指标。页签 hash 支持刷新与前进后退,桌面端页签吸顶,移动端顶栏操作收进菜单、运营指标与页签可横向滑动;1440px/390px 浏览器渲染、六页签与深链交互检查通过,无 API、schema、migration 或运行方式变化。
|
||||
|
||||
- **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4,单用户上限由 2 提升到 3;前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ from __future__ import annotations
|
|||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
|
|
@ -47,6 +49,21 @@ from core.storage.telemetry import (
|
|||
KIND_TOOL_SALVAGED,
|
||||
)
|
||||
|
||||
|
||||
_CACHE_TTL_SECONDS = 90.0
|
||||
_failure_cache_lock = threading.Lock()
|
||||
_wire_cache_lock = threading.Lock()
|
||||
_failure_cache: dict[tuple[float, int, int], tuple[float, List[Dict[str, Any]]]] = {}
|
||||
_wire_cache: dict[int, tuple[float, Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _clear_tool_health_cache() -> None:
|
||||
"""测试/运维进程内失效入口;不触碰数据库。"""
|
||||
with _failure_cache_lock:
|
||||
_failure_cache.clear()
|
||||
with _wire_cache_lock:
|
||||
_wire_cache.clear()
|
||||
|
||||
# 签名归一:同一类错误在不同 task/参数下的差异(路径/数字/uuid/十六进制)抹平,
|
||||
# 让 "figures/a.png doesn't exist" 和 "figures/b.png doesn't exist" 聚成一条。
|
||||
_RE_UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
||||
|
|
@ -321,6 +338,30 @@ def scan_tool_failures(
|
|||
return out
|
||||
|
||||
|
||||
def scan_tool_failures_cached(
|
||||
days: float = 7,
|
||||
min_count: int = 5,
|
||||
min_tasks: int = 2,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""管理页短时缓存;锁覆盖扫描,使同参数并发请求只扫库一次。"""
|
||||
key = (float(days), int(min_count), int(min_tasks))
|
||||
now = time.monotonic()
|
||||
with _failure_cache_lock:
|
||||
cached = _failure_cache.get(key)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
for stale_key, (expires_at, _value) in list(_failure_cache.items()):
|
||||
if expires_at <= now:
|
||||
_failure_cache.pop(stale_key, None)
|
||||
value = scan_tool_failures(
|
||||
days=days,
|
||||
min_count=min_count,
|
||||
min_tasks=min_tasks,
|
||||
)
|
||||
_failure_cache[key] = (time.monotonic() + _CACHE_TTL_SECONDS, value)
|
||||
return value
|
||||
|
||||
|
||||
def scan_tool_wire_health(days: int = 7) -> Dict[str, Any]:
|
||||
"""聚合 provider 工具参数损坏的抢救/残余比例;纯只读、无派生状态。
|
||||
|
||||
|
|
@ -407,6 +448,22 @@ def scan_tool_wire_health(days: int = 7) -> Dict[str, Any]:
|
|||
return {"days": days, "rows": out, "total": totals}
|
||||
|
||||
|
||||
def scan_tool_wire_health_cached(days: int = 7) -> Dict[str, Any]:
|
||||
"""管理页链路健康短时缓存;同窗口并发请求共享一次聚合。"""
|
||||
key = min(90, max(1, int(days)))
|
||||
now = time.monotonic()
|
||||
with _wire_cache_lock:
|
||||
cached = _wire_cache.get(key)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
for stale_key, (expires_at, _value) in list(_wire_cache.items()):
|
||||
if expires_at <= now:
|
||||
_wire_cache.pop(stale_key, None)
|
||||
value = scan_tool_wire_health(days=key)
|
||||
_wire_cache[key] = (time.monotonic() + _CACHE_TTL_SECONDS, value)
|
||||
return value
|
||||
|
||||
|
||||
# ── provider 级致命错误即时告警 ──
|
||||
# 命中判据:错误文案含余额/配额/认证类关键词 —— 这类错误不是单任务偶发,而是该
|
||||
# provider 上所有 run 全挂(如 Zai 余额不足),等日巡检的 5 次/2 task 阈值太慢。
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ class StaticVendorTests(unittest.TestCase):
|
|||
self.assertIn('data-summary-tab="${escapeHtml(tab)}"', admin_js)
|
||||
self.assertIn("function renderCapacityDrawer()", admin_js)
|
||||
self.assertIn("function openCapacityDrawer()", admin_js)
|
||||
self.assertIn("const TOOL_HEALTH_REFRESH_MS = 90000", admin_js)
|
||||
self.assertIn("if (toolFailuresRequest) return toolFailuresRequest", admin_js)
|
||||
self.assertIn("loadToolFailures();", admin_js)
|
||||
self.assertNotIn('activeTab === "quality"', admin_js)
|
||||
self.assertIn('id="capacity-drawer" class="capacity-drawer"', html)
|
||||
self.assertIn(".capacity-drawer-panel {", html)
|
||||
|
||||
|
|
|
|||
|
|
@ -226,6 +226,34 @@ class TestToolWireHealth(unittest.TestCase):
|
|||
self.assertIsNone(out["total"]["recovery_rate_24h"])
|
||||
|
||||
|
||||
class TestToolHealthCache(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tf._clear_tool_health_cache()
|
||||
|
||||
def tearDown(self):
|
||||
tf._clear_tool_health_cache()
|
||||
|
||||
def test_failure_scan_is_reused_within_ttl(self):
|
||||
expected = [{"tool": "shell"}]
|
||||
with patch.object(tf, "scan_tool_failures", return_value=expected) as scan:
|
||||
first = tf.scan_tool_failures_cached(days=7, min_count=3, min_tasks=1)
|
||||
second = tf.scan_tool_failures_cached(days=7, min_count=3, min_tasks=1)
|
||||
|
||||
self.assertIs(first, expected)
|
||||
self.assertIs(second, expected)
|
||||
scan.assert_called_once_with(days=7, min_count=3, min_tasks=1)
|
||||
|
||||
def test_wire_scan_normalizes_window_before_caching(self):
|
||||
expected = {"days": 90, "rows": [], "total": {}}
|
||||
with patch.object(tf, "scan_tool_wire_health", return_value=expected) as scan:
|
||||
first = tf.scan_tool_wire_health_cached(days=365)
|
||||
second = tf.scan_tool_wire_health_cached(days=90)
|
||||
|
||||
self.assertIs(first, expected)
|
||||
self.assertIs(second, expected)
|
||||
scan.assert_called_once_with(days=90)
|
||||
|
||||
|
||||
class TestProviderCriticalAlert(unittest.TestCase):
|
||||
def setUp(self):
|
||||
tf._alerted_at.clear()
|
||||
|
|
|
|||
|
|
@ -533,11 +533,11 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
|
|||
):
|
||||
"""工具失败聚集(core/toolfail 同款扫描,页面用低阈值看全量;
|
||||
巡检推送走 5 次/2 task 的高阈值)。admin-only。"""
|
||||
from core.toolfail import scan_tool_failures
|
||||
from core.toolfail import scan_tool_failures_cached
|
||||
days = min(90, max(1, days))
|
||||
return {
|
||||
"days": days,
|
||||
"clusters": scan_tool_failures(
|
||||
"clusters": scan_tool_failures_cached(
|
||||
days=days, min_count=max(1, min_count), min_tasks=max(1, min_tasks)
|
||||
),
|
||||
}
|
||||
|
|
@ -547,8 +547,8 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
|
|||
days: int = 7, user_id: UUID = Depends(require_admin),
|
||||
):
|
||||
"""工具参数 wire 损坏的抢救率,按模型档+工具聚合;admin-only、纯只读。"""
|
||||
from core.toolfail import scan_tool_wire_health
|
||||
return scan_tool_wire_health(days=days)
|
||||
from core.toolfail import scan_tool_wire_health_cached
|
||||
return scan_tool_wire_health_cached(days=days)
|
||||
|
||||
@app.get("/v1/admin/tiers", tags=["admin"])
|
||||
def admin_tiers(user_id: UUID = Depends(require_admin)):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
|
|||
|
||||
const LS_TOKEN = "zcbot.token";
|
||||
const REFRESH_MS = 10000;
|
||||
// 工具健康需要聚合近 7 天日志:进入 Admin 立即异步读取,之后最多 90s 刷新一次;
|
||||
// 后端另有同周期缓存,多个管理员也不会重复扫库。
|
||||
const TOOL_HEALTH_REFRESH_MS = 90000;
|
||||
const PAGE_SIZE = 20;
|
||||
const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]];
|
||||
const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
|
||||
|
|
@ -65,6 +68,8 @@ let sandboxCapacityData = null;
|
|||
let storageSummaryData = null;
|
||||
let toolFailuresData = null;
|
||||
let toolWireData = null;
|
||||
let toolFailuresRequest = null;
|
||||
let toolFailuresLastAttemptAt = 0;
|
||||
let capacityDrawerTrigger = null;
|
||||
|
||||
// ───── 格式化 ─────
|
||||
|
|
@ -1480,21 +1485,32 @@ async function loadStorageSummary() {
|
|||
} catch (e) { /* 保留最近一次成功的摘要 */ }
|
||||
}
|
||||
|
||||
async function loadToolFailures() {
|
||||
async function loadToolFailures(force = false) {
|
||||
const now = Date.now();
|
||||
if (toolFailuresRequest) return toolFailuresRequest;
|
||||
if (!force && now - toolFailuresLastAttemptAt < TOOL_HEALTH_REFRESH_MS) return;
|
||||
toolFailuresLastAttemptAt = now;
|
||||
toolFailuresRequest = (async () => {
|
||||
try {
|
||||
const [failures, wire] = await Promise.allSettled([
|
||||
apiGet("/v1/admin/tool-failures?days=7&min_count=3&min_tasks=1"),
|
||||
apiGet("/v1/admin/tool-wire-health?days=7"),
|
||||
]);
|
||||
if (failures.status !== "fulfilled") throw failures.reason;
|
||||
toolFailuresData = failures.value;
|
||||
toolWireData = wire.status === "fulfilled" ? wire.value : null;
|
||||
renderToolFailures(
|
||||
failures.value,
|
||||
toolWireData,
|
||||
);
|
||||
renderOpsSummary();
|
||||
} catch (e) { /* 同上 */ }
|
||||
})();
|
||||
try {
|
||||
const [failures, wire] = await Promise.allSettled([
|
||||
apiGet("/v1/admin/tool-failures?days=7&min_count=3&min_tasks=1"),
|
||||
apiGet("/v1/admin/tool-wire-health?days=7"),
|
||||
]);
|
||||
if (failures.status !== "fulfilled") throw failures.reason;
|
||||
toolFailuresData = failures.value;
|
||||
toolWireData = wire.status === "fulfilled" ? wire.value : null;
|
||||
renderToolFailures(
|
||||
failures.value,
|
||||
toolWireData,
|
||||
);
|
||||
renderOpsSummary();
|
||||
} catch (e) { /* 同上 */ }
|
||||
await toolFailuresRequest;
|
||||
} finally {
|
||||
toolFailuresRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalDefinitions(force = false) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue