From edbaedce15c21c74dc2c3f89ee6e68bbf779f413 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Sun, 6 Sep 2026 02:32:35 +0800 Subject: [PATCH] =?UTF-8?q?v2.6.0=20=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5+?= =?UTF-8?q?=E7=AD=9B=E9=80=89+=E6=90=9C=E7=B4=A2+=E8=A1=A8=E5=A4=B4?= =?UTF-8?q?=E5=8D=87=E9=99=8D=E6=8E=92=E5=BA=8F=EF=BC=9B=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E5=8E=86=E5=8F=B2(DELETE=20/api/tests)?= =?UTF-8?q?=EF=BC=9B=E5=8F=B3=E4=B8=8B=E8=A7=92=E7=BD=AE=E9=A1=B6/?= =?UTF-8?q?=E7=BD=AE=E5=BA=95=E6=82=AC=E6=B5=AE=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 29 ++++++-- README.md | 10 ++- app.py | 25 ++++++- database.py | 73 +++++++++++++++++- static/css/style.css | 34 +++++++++ static/index.html | 51 ++++++++++++- static/js/app.js | 173 +++++++++++++++++++++++++++++++++++-------- 7 files changed, 346 insertions(+), 49 deletions(-) diff --git a/API.md b/API.md index 04f66be..7b52817 100644 --- a/API.md +++ b/API.md @@ -137,20 +137,32 @@ **响应:** `{ "ok": true, "id": 9 }` -### `GET /api/tests?limit=` -测试历史列表(按 id 倒序)。`limit` 默认 100,最大 1000。 +### `GET /api/tests` +测试历史列表,支持**分页 + 搜索 + 筛选 + 排序**: + +| 参数 | 说明 | +|------|------| +| `page` | 页码,默认 1 | +| `page_size` | 每页条数,默认 20,最大 100 | +| `q` | 关键词搜索(匹配 id/名称/模型/提供商/状态/时间) | +| `status` | 状态筛选,逗号多选:`done,error,canceled,running` | +| `provider` | 提供商筛选:`openai` / `anthropic` / `google` | +| `sort` | 排序字段:`id`/`created_at`/`name`/`provider`/`model`/`status`/`samples`/`ttft`/`prefill`/`decode`/`total_ms`,默认 `id` | +| `order` | 排序方向:`asc` / `desc`,默认 `desc`(数值字段空值恒排最后) | **响应:** ```json -[ - { "id": 9, "created_at": "2026-08-23 18:52:00", "status": "done", +{ "ok": true, "items": [ { "id": 9, "created_at": "2026-08-23 18:52:00", "status": "done", "provider": "openai", "model": "unsloth/Qwen3.8-27B-Q4_K_M", "name": "Qwen3 不同上下文长度速度对比", "error": "", - "summary": { "samples_ok": 2, "samples_total": 2, "avg_ttft_ms": 1808.7, ... } } -] + "summary": { "samples_ok": 2, "samples_total": 2, "avg_ttft_ms": 1808.7, ... } } ], + "total": 31, "page": 1, "page_size": 20, "total_pages": 2 } ``` +### `DELETE /api/tests` +一键清空全部测试历史(测试记录 + 采样指标 + 日志),不可恢复。响应 `{ "ok": true }`。 + ### `POST /api/tests//cancel` 停止正在运行的测试。响应 `{ "ok": true, "msg": "正在停止..." }` @@ -353,7 +365,10 @@ curl -X POST $BASE/api/tests -H 'Content-Type: application/json' -d '{ }' # 查询测试列表 / 详情 -curl "$BASE/api/tests?limit=10" +curl "$BASE/api/tests?page=1&page_size=20" +curl "$BASE/api/tests?q=Qwen&status=done,error&sort=decode&order=desc&page=1&page_size=10" +# 一键清空全部历史(不可恢复) +curl -X DELETE $BASE/api/tests curl $BASE/api/tests/9 # 画图数据(CSV) diff --git a/README.md b/README.md index b907806..97b0953 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - **访问地址:** `http://:16097/` - **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖) -- **版本:** v2.5.1 +- **版本:** v2.6.0 --- @@ -40,6 +40,9 @@ - **文件下载 Excel(xlsx)**:历史记录「Excel」按钮或详情弹窗「导出 Excel」,包含 汇总 / 采样明细 / 日志 三个 Sheet - **文件下载 JSON**:详情弹窗「导出 JSON」 - 测试历史留存(含测试名称),可随时刷新、查看、导出、删除 +- **📄 历史分页 + 筛选 + 搜索 + 排序**:历史列表支持分页(10/20/50/100 条每页)、多条件筛选(状态多选、提供商)、关键词搜索(编号/名称/模型/提供商/状态/时间)、点击表头升降排序(#/时间/名称/提供商/模型/采样/首字/预填充/解码/状态,数值列空值恒排最后);提供「✕ 重置」一键恢复默认 +- **🗑 一键清空历史**:历史卡片右上角「清空历史」二次确认后,删除全部测试记录/采样/日志(不可恢复) +- **⬆⬇ 一键置顶/置底**:右下角固定悬浮按钮,平滑滚动到页面顶部/底部 - **⚖️ 多测试结果对比**:测试历史表格勾选多个测试(可全选),点「⚖️ 对比所选」弹出对比面板: - **指标对比表**:时间 / 名称 / 模型 / 并发档 / 采样 / 首字 / 预填充 / 解码 / 单流均解码 / 输出 / 总耗时 并排展示(点击测试名可跳详情) - **柱状图**:各测试预填充(空心柱)vs 解码(实心柱)速度对比 @@ -101,7 +104,8 @@ pip install -r requirements.txt | GET/POST | `/api/configs` | 配置列表 / 新增配置 | | GET/PUT/DELETE | `/api/configs/` | 单个配置 / 更新(局部)/ 删除 | | POST | `/api/configs/test` | 测试连接 | -| GET/POST | `/api/tests?limit=n` | 测试历史 / 启动测试(异步) | +| GET/POST | `/api/tests?page=&page_size=&q=&status=&provider=&sort=&order=` | 测试历史(分页/搜索/筛选/排序)/ 启动测试(异步) | +| DELETE | `/api/tests` | 一键清空全部测试历史(含采样与日志) | | GET | `/api/tests/` | 测试详情(含 runs / logs / summary / 按长度分组) | | GET | `/api/tests//logs?after=` | 增量日志(前端轮询用) | | POST | `/api/tests//cancel` | 停止测试 | @@ -179,4 +183,4 @@ llm-speed-tester/ ## Git - **仓库:** `hz4th_coder/llm-speed-tester` -- **版本:** v2.5.1(修复解码速度荒谬值:兼容 vLLM `reasoning` 字段思维链 + 幻影token防护(usage 有 token 但流无正文时解码速度置空)) +- **版本:** v2.6.0(历史分页+筛选+搜索+表头排序;一键清空历史;右下角置顶/置底悬浮按钮) diff --git a/app.py b/app.py index 061b333..2d1e2fd 100644 --- a/app.py +++ b/app.py @@ -148,11 +148,30 @@ def start_test(): @app.route("/api/tests", methods=["GET"]) def list_tests(): + """历史记录列表:支持关键词搜索 q、状态 status(逗号多选)、提供商 provider、 + 字段排序 sort/order、分页 page/page_size""" try: - limit = int(request.args.get("limit", 100)) + page = max(1, int(request.args.get("page", 1))) + page_size = min(100, max(1, int(request.args.get("page_size", 20)))) except ValueError: - limit = 100 - return jsonify(db.list_tests(max(1, min(limit, 1000)))) + page, page_size = 1, 20 + q = (request.args.get("q") or "").strip() + status = (request.args.get("status") or "").strip() + provider = (request.args.get("provider") or "").strip() + sort = (request.args.get("sort") or "id").strip() + order = (request.args.get("order") or "desc").strip().lower() + if order not in ("asc", "desc"): + order = "desc" + data = db.query_tests(q=q, status=status, provider=provider, + sort=sort, order=order, page=page, page_size=page_size) + return jsonify({"ok": True, **data}) + + +@app.route("/api/tests", methods=["DELETE"]) +def clear_tests(): + """一键清空全部测试历史(含采样指标与日志)""" + db.clear_tests() + return jsonify({"ok": True}) @app.route("/api/tests/", methods=["GET"]) diff --git a/database.py b/database.py index ee10d02..0679930 100644 --- a/database.py +++ b/database.py @@ -2,6 +2,7 @@ """SQLite 存储:提供商配置 / 测试记录 / 每次采样指标 / 全量日志""" import os import json +import math import sqlite3 import threading import time @@ -204,20 +205,86 @@ def get_test(tid: int): conn.close() -def list_tests(limit=100): +# 历史列表排序字段 → 取值函数(数值字段取 summary 聚合值,None 恒排最后) +SORT_KEYS = { + "id": lambda t: t["id"], + "created_at": lambda t: t["created_at"], + "name": lambda t: (t.get("name") or "").lower(), + "provider": lambda t: (t.get("provider") or "").lower(), + "model": lambda t: (t.get("model") or "").lower(), + "status": lambda t: t.get("status") or "", + "samples": lambda t: (t.get("summary") or {}).get("samples_ok"), + "ttft": lambda t: (t.get("summary") or {}).get("avg_ttft_ms"), + "prefill": lambda t: (t.get("summary") or {}).get("avg_prefill_speed"), + "decode": lambda t: (t.get("summary") or {}).get("avg_decode_speed"), + "total_ms": lambda t: (t.get("summary") or {}).get("avg_total_ms"), +} + + +def query_tests(q="", status="", provider="", sort="id", order="desc", + page=1, page_size=20): + """历史记录:关键词搜索 + 状态/提供商筛选 + 字段排序 + 分页。 + 返回 {"items": [...], "total": N, "page": p, "page_size": s, "total_pages": n}""" with _lock: conn = _connect() try: + where, args = [], [] + if q: + like = "%" + q + "%" + where.append("(id LIKE ? OR name LIKE ? OR model LIKE ? OR provider LIKE ?" + " OR status LIKE ? OR created_at LIKE ?)") + args += [like] * 6 + if status: + sts = [s.strip() for s in status.split(",") if s.strip()] + if sts: + where.append("status IN (%s)" % ",".join("?" * len(sts))) + args += sts + if provider: + where.append("provider = ?") + args.append(provider) + wsql = ("WHERE " + " AND ".join(where)) if where else "" + rows = conn.execute( "SELECT id,created_at,status,provider,model,name,summary_json,gen_cfg_json,error " - "FROM tests ORDER BY id DESC LIMIT ?", (limit,)).fetchall() + "FROM tests " + wsql + " ORDER BY id DESC", args).fetchall() out = [] for r in rows: d = dict(r) d["summary"] = json.loads(d.pop("summary_json") or "{}") d["gen"] = json.loads(d.pop("gen_cfg_json") or "{}") out.append(d) - return out + + # Python 侧排序:None 值恒排最后(无论升降序) + keyfn = SORT_KEYS.get(sort) or SORT_KEYS["id"] + present = [t for t in out if keyfn(t) is not None] + absent = [t for t in out if keyfn(t) is None] + present.sort(key=keyfn, reverse=(order == "desc")) + out = present + absent + + total = len(out) + total_pages = max(1, math.ceil(total / page_size)) + page = min(max(1, page), total_pages) + start = (page - 1) * page_size + return { + "items": out[start:start + page_size], + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } + finally: + conn.close() + + +def clear_tests(): + """一键清空全部历史(测试记录 + 采样指标 + 日志)""" + with _lock: + conn = _connect() + try: + conn.execute("DELETE FROM test_runs") + conn.execute("DELETE FROM logs") + conn.execute("DELETE FROM tests") + conn.commit() finally: conn.close() diff --git a/static/css/style.css b/static/css/style.css index 51132c1..fbcacd4 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -184,6 +184,17 @@ body { .console .ln.sys { color: #5b6b8c; font-style: italic; } /* ── 历史表 ── */ +.hist-filters { + display: flex; flex-wrap: wrap; gap: 6px; align-items: center; + margin-bottom: 10px; +} +.hist-filters select { + background: var(--panel2); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 7px 10px; font-size: 12.5px; outline: none; +} +.hist-filters select:focus { border-color: var(--accent); } +.hist-filters .icon-input { min-width: 160px; } + .table-wrap { overflow-x: auto; } table.history { width: 100%; border-collapse: collapse; font-size: 13px; } .history th, .history td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; } @@ -192,6 +203,17 @@ table.history { width: 100%; border-collapse: collapse; font-size: 13px; } .history td.num { font-family: var(--mono); } .history .chk-col { width: 34px; text-align: center; } .history .chk-col input { width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent); } +.history th.sortable { cursor: pointer; user-select: none; } +.history th.sortable:hover { color: var(--accent); } +.history th.sortable.asc::after { content: " ▲"; font-size: 10px; } +.history th.sortable.desc::after { content: " ▼"; font-size: 10px; } + +/* 历史分页条 */ +.hist-pager { + display: flex; align-items: center; justify-content: center; gap: 14px; + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); +} +.hist-pager .btn:disabled { opacity: .4; cursor: not-allowed; } .status-pill { padding: 2px 10px; border-radius: 12px; font-size: 11px; } .status-pill.running { background: rgba(79,140,255,.15); color: var(--accent); } .status-pill.done { background: rgba(34,197,139,.15); color: var(--accent2); } @@ -257,6 +279,18 @@ pre.json-box { padding: 10px; font: 11.5px/1.6 var(--mono); overflow: auto; max-height: 260px; color: #a8f0d0; } +/* ── 右下角一键置顶/置底 ── */ +.scroll-fab { + position: fixed; right: 20px; bottom: 20px; + display: flex; flex-direction: column; gap: 8px; z-index: 60; +} +.fab { + width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border); + background: var(--panel2); color: var(--text); font-size: 16px; cursor: pointer; + box-shadow: 0 4px 14px rgba(0,0,0,.45); transition: .15s; line-height: 1; +} +.fab:hover { border-color: var(--accent); color: var(--accent); } + @media (max-width: 1000px) { .layout { grid-template-columns: 1fr; } .config-panel { position: static; } diff --git a/static/index.html b/static/index.html index c5ec6e0..acd8cd3 100644 --- a/static/index.html +++ b/static/index.html @@ -175,23 +175,62 @@
-

🗂 测试历史

+

🗂 测试历史

+
+
+
🔍
+ + + + +
- - + + + + + + + + + + + +
#时间名称提供商模型并发采样首字 ms预填充 tok/s解码 tok/s状态操作#时间名称提供商模型并发采样首字 ms预填充 tok/s解码 tok/s状态操作
+
+ + + +
@@ -223,6 +262,12 @@ + +
+ + +
+ diff --git a/static/js/app.js b/static/js/app.js index c608aac..11ed298 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -422,6 +422,7 @@ function stopTest() { /* ───────────────────────── 测试历史 ───────────────────────── */ let histSelected = new Set(); // 勾选用于对比的测试 id +const histState = { page: 1, pageSize: 20, q: "", status: "", provider: "", sort: "id", order: "desc" }; function updateHistSelectedLabel() { const el = $("#hist-selected"); @@ -429,43 +430,74 @@ function updateHistSelectedLabel() { } async function loadHistory() { - const list = await api("/api/tests"); + const p = new URLSearchParams({ + page: histState.page, page_size: histState.pageSize, + q: histState.q, status: histState.status, provider: histState.provider, + sort: histState.sort, order: histState.order, + }); + let d; + try { + d = await api("/api/tests?" + p.toString()); + } catch (e) { return; } + if (!d || !d.ok) return; + renderHistory(d); +} + +function renderHistory(d) { + const list = d.items || []; const tb = $("#history tbody"); tb.innerHTML = ""; const selAll = $("#hist-check-all"); if (selAll) selAll.checked = false; + const hasFilter = histState.q || histState.status || histState.provider; + $("#hist-total").textContent = d.total ? `(共 ${d.total} 条)` : ""; if (!list.length) { - tb.innerHTML = '暂无测试记录'; + tb.innerHTML = `暂无${hasFilter ? "匹配的" : ""}测试记录${hasFilter ? "(试试调整筛选条件)" : ""}`; updateHistSelectedLabel(); - return; - } - for (const t of list) { - const s = t.summary || {}; - const g = t.gen || {}; - const cls = s.concurrency_levels || g.concurrency_levels || [1]; - const tr = document.createElement("tr"); - tr.innerHTML = ` - - #${t.id} - ${esc(t.created_at)} - ${esc(t.name || "—")} - ${esc(PROVIDER_LABEL[t.provider] || t.provider)} - ${esc(t.model)} - ${esc(cls.join("/"))} - ${fmt(s.samples_ok)}/${fmt(s.samples_total)} - ${fmt(s.avg_ttft_ms)} - ${fmt(s.avg_prefill_speed)} - ${fmt(s.avg_decode_speed)} - ${STATUS_LABEL[t.status] || t.status} - - - - - - `; - tb.appendChild(tr); + } else { + for (const t of list) { + const s = t.summary || {}; + const g = t.gen || {}; + const cls = s.concurrency_levels || g.concurrency_levels || [1]; + const tr = document.createElement("tr"); + tr.innerHTML = ` + + #${t.id} + ${esc(t.created_at)} + ${esc(t.name || "—")} + ${esc(PROVIDER_LABEL[t.provider] || t.provider)} + ${esc(t.model)} + ${esc(cls.join("/"))} + ${fmt(s.samples_ok)}/${fmt(s.samples_total)} + ${fmt(s.avg_ttft_ms)} + ${fmt(s.avg_prefill_speed)} + ${fmt(s.avg_decode_speed)} + ${STATUS_LABEL[t.status] || t.status} + + + + + + `; + tb.appendChild(tr); + } } updateHistSelectedLabel(); + + // 分页信息 + const totalPages = Math.max(1, d.total_pages || 1); + $("#hist-page-info").textContent = `第 ${d.page} / ${totalPages} 页 · 共 ${d.total} 条`; + $("#hist-prev").disabled = d.page <= 1; + $("#hist-next").disabled = d.page >= totalPages; + histState.page = d.page; + updateSortIndicators(); +} + +function updateSortIndicators() { + $$("#history thead th.sortable").forEach((th) => { + th.classList.remove("asc", "desc"); + if (th.dataset.sort === histState.sort) th.classList.add(histState.order); + }); } /* ───────────────────────── 多测试对比 ───────────────────────── */ @@ -1064,8 +1096,57 @@ function bind() { $("#btn-clear-console").addEventListener("click", clearConsole); $("#btn-export-log").addEventListener("click", exportCurrentLog); - $("#btn-refresh-history").addEventListener("click", loadHistory); + $("#btn-refresh-history").addEventListener("click", () => { histState.page = 1; loadHistory(); }); $("#btn-compare").addEventListener("click", openCompare); + $("#btn-clear-history").addEventListener("click", clearAllHistory); + + // 历史:筛选 / 搜索 / 每页条数 / 分页 / 重置 + let histQTimer = null; + $("#hist-q").addEventListener("input", () => { + clearTimeout(histQTimer); + histQTimer = setTimeout(() => { + histState.q = $("#hist-q").value.trim(); + histState.page = 1; + loadHistory(); + }, 300); + }); + $("#hist-status").addEventListener("change", () => { + histState.status = $("#hist-status").value; + histState.page = 1; + loadHistory(); + }); + $("#hist-provider").addEventListener("change", () => { + histState.provider = $("#hist-provider").value; + histState.page = 1; + loadHistory(); + }); + $("#hist-size").addEventListener("change", () => { + histState.pageSize = parseInt($("#hist-size").value) || 20; + histState.page = 1; + loadHistory(); + }); + $("#hist-prev").addEventListener("click", () => { + if (histState.page > 1) { histState.page--; loadHistory(); } + }); + $("#hist-next").addEventListener("click", () => { + if (!$("#hist-next").disabled) { histState.page++; loadHistory(); } + }); + $("#hist-reset").addEventListener("click", resetHistoryFilters); + + // 历史:点击表头升降排序 + $("#history thead").addEventListener("click", (e) => { + const th = e.target.closest("th.sortable"); + if (!th) return; + const key = th.dataset.sort; + if (histState.sort === key) { + histState.order = histState.order === "asc" ? "desc" : "asc"; + } else { + histState.sort = key; + histState.order = (key === "id" || key === "created_at") ? "desc" : "asc"; + } + histState.page = 1; + loadHistory(); + }); $("#history tbody").addEventListener("click", (e) => { const v = e.target.closest("[data-view]"); @@ -1108,6 +1189,38 @@ function bind() { $("#dt-send-eval").addEventListener("click", () => { if (window.__detail) sendToEval(window.__detail.id); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideModelPick(); closeDetail(); closeCompare(); } }); + + // 右下角一键置顶 / 置底 + $("#fab-top").addEventListener("click", () => window.scrollTo({ top: 0, behavior: "smooth" })); + $("#fab-bottom").addEventListener("click", () => + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" })); +} + +/* ───────────────────────── 历史筛选工具 ───────────────────────── */ + +function resetHistoryFilters() { + histState.q = ""; histState.status = ""; histState.provider = ""; + histState.sort = "id"; histState.order = "desc"; + histState.page = 1; + $("#hist-q").value = ""; + $("#hist-status").value = ""; + $("#hist-provider").value = ""; + loadHistory(); +} + +async function clearAllHistory() { + if (!confirm("确定清空全部测试历史?\n所有测试记录、采样数据与日志将被永久删除,无法恢复!")) return; + if (!confirm("再次确认:真的要清空全部历史记录吗?")) return; + const r = await api("/api/tests", "DELETE"); + if (r.ok) { + histSelected.clear(); + histState.page = 1; + toast("✅ 历史记录已全部清空"); + loadHistory(); + updateHistSelectedLabel(); + } else { + toast("❌ 清空失败:" + (r.error || "未知错误")); + } } /* ───────────────────────── 初始化 ───────────────────────── */