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