@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loadcase.py`(558 行,仓库根)的 Node 对应物(Node 后端 M6-5)。
|
|
3
|
+
*
|
|
4
|
+
* 压测方案包(load case)解析与指标通道工具(task_type=stress 专用):
|
|
5
|
+
* - 方案包路径解析与四级回落(run.py → scenario.json → 存量 scenario_<id>.json → 缺失);
|
|
6
|
+
* - 方案资产读取(plan.md / 脚本源码 / charts.json 图表声明 / panel.html 存在性);
|
|
7
|
+
* - 图表声明校验(非法时整份回退内置默认面板,不阻断发压);
|
|
8
|
+
* - 指标行归一(旧快照行 → 规范样本,供旧任务继续出图);
|
|
9
|
+
* - 指标收尾行(summary)读取与脚本驱动的最小报告落盘。
|
|
10
|
+
*
|
|
11
|
+
* ── 导出名 ────────────────────────────────────────────────────────────────────
|
|
12
|
+
* 主名**照 Python 名**(`validate_charts`/`load_charts`/`resolve`/…),文件末尾再给一套 camelCase
|
|
13
|
+
* 别名供 JS 调用方使用(与 `rag.js`/`loadgen.js` 同一约定)。对拍用例的 `fn` 写 Python 名、
|
|
14
|
+
* `nodeFn` 写本文件的导出名。
|
|
15
|
+
*
|
|
16
|
+
* ── 与 loadgen 的**共用**(硬约束) ──────────────────────────────────────────
|
|
17
|
+
* `loadcase.py:467/471` 直接调 `loadgen._pct_from_buckets`;Node 侧同样只保留**一份**实现:
|
|
18
|
+
* `_pct_from_buckets` 定义在 `loadgen.js`(`bisect` + 桶内线性插值 + `round(...,1)`),本文件
|
|
19
|
+
* import 它,不另写一份。下面 import 的 `py_float`/`py_error_str`/`read_json_file`/`py_field_str`/
|
|
20
|
+
* `strftime_local` 是 **Node 侧共用小工具**(Python 无对应物,都是「复刻 CPython 语义」的 shim):
|
|
21
|
+
* 放在 loadgen.js 是为了两文件共用一份实现,不重复造。
|
|
22
|
+
*
|
|
23
|
+
* ── 已登记的**残余差异**(原理性;不做粉饰,逐条给理由) ──────────────────────
|
|
24
|
+
* a. **整数值浮点的 JSON 文本**:`write_minimal_report` 落盘的 `indent=1` JSON 里,Python 写
|
|
25
|
+
* `1.0`、`pyjson.js` 写 `1`(`pyjson.js` 文件头已列为不可消除)。对拍夹具刻意避开整数值浮点。
|
|
26
|
+
* b. **JSON 解码错误文案**:CPython 的 `Expecting value: line 1 column 1 (char 0)` vs V8 的
|
|
27
|
+
* `Unexpected token …`——分支一致(两侧都判「不是合法 JSON」),文案不同源。
|
|
28
|
+
* c. `str.isdigit()` 用 `\p{Nd}\p{No}` 复刻(Python 的「十进制数字 + Digit 属性的 No」),
|
|
29
|
+
* 长度按**码点**数;非 BMP 数字(U+1D7CE 段)与 Python 也一致。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
35
|
+
|
|
36
|
+
import { isFile, pyBasename, pyMtimeFloat, pyStrCompare, pyStrip } from './lib.js';
|
|
37
|
+
import { pyJsonDumpsIndent } from './pyjson.js';
|
|
38
|
+
import { ValueError, pyInt, pyStr, pyTruthy } from './rag.js';
|
|
39
|
+
import {
|
|
40
|
+
_pct_from_buckets, py_error_str, py_field_str, py_float, read_json_file, strftime_local,
|
|
41
|
+
} from './loadgen.js';
|
|
42
|
+
|
|
43
|
+
// ---------- 方案包文件名与驱动类型 ----------
|
|
44
|
+
|
|
45
|
+
export const LOAD_DIR = 'load';
|
|
46
|
+
// 运行键格式:一次发压运行的开始时刻(秒级),日志/指标/快照/报告都按它切分
|
|
47
|
+
export const RUN_KEY_FMT = '%Y%m%d_%H%M%S';
|
|
48
|
+
export const PLAN_NAME = 'plan.md';
|
|
49
|
+
export const SCENARIO_NAME = 'scenario.json';
|
|
50
|
+
export const SCRIPT_NAME = 'run.py';
|
|
51
|
+
export const CHARTS_NAME = 'charts.json';
|
|
52
|
+
export const HTML_NAME = 'panel.html';
|
|
53
|
+
|
|
54
|
+
// 驱动类型:脚本 / 场景(平台调 loadgen CLI) / 存量场景(旧布局)
|
|
55
|
+
export const DRIVER_SCRIPT = 'script';
|
|
56
|
+
export const DRIVER_SCENARIO = 'scenario';
|
|
57
|
+
export const DRIVER_LEGACY = 'legacy_scenario';
|
|
58
|
+
|
|
59
|
+
// 指标样本类型与面板类型(校验白名单)
|
|
60
|
+
export const SAMPLE_TYPES = Object.freeze(['gauge', 'counter']);
|
|
61
|
+
export const PANEL_TYPES = Object.freeze(['line', 'bar', 'stacked_bar', 'donut', 'stat', 'table']);
|
|
62
|
+
export const PANEL_WIDTHS = Object.freeze(['half', 'full']);
|
|
63
|
+
// 单个面板声明的序列/指标卡上限(防畸形声明把前端拖死)
|
|
64
|
+
export const MAX_PANELS = 24;
|
|
65
|
+
export const MAX_SERIES = 12;
|
|
66
|
+
export const MAX_STATS = 8;
|
|
67
|
+
// 资产源码回传前端的上限(字节,超出截断并标注)
|
|
68
|
+
export const ASSET_TEXT_MAX = 200 * 1024;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 内置默认面板:case 未声明 charts.json(或声明非法)时使用,
|
|
72
|
+
* 口径与旧压测面板一致(RPS / 延迟分位 / 错误率 + KPI 卡 + 分接口表)。
|
|
73
|
+
*
|
|
74
|
+
* **回落时返回的就是这个对象本身**(Python `load_charts` 返回同一个 `DEFAULT_CHARTS`),
|
|
75
|
+
* 调用方不要改它——要用请先 `structuredClone`。
|
|
76
|
+
*/
|
|
77
|
+
export const DEFAULT_CHARTS = {
|
|
78
|
+
version: 1,
|
|
79
|
+
title: '压测指标',
|
|
80
|
+
panels: [
|
|
81
|
+
{
|
|
82
|
+
id: 'rps',
|
|
83
|
+
type: 'line',
|
|
84
|
+
title: '吞吐 RPS',
|
|
85
|
+
width: 'full',
|
|
86
|
+
series: [{ metric: 'rps', label: '总 RPS' }],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'lat',
|
|
90
|
+
type: 'line',
|
|
91
|
+
title: '延迟分位 (ms)',
|
|
92
|
+
width: 'half',
|
|
93
|
+
series: [
|
|
94
|
+
{ metric: 'lat.p50', label: 'P50' },
|
|
95
|
+
{ metric: 'lat.p95', label: 'P95' },
|
|
96
|
+
{ metric: 'lat.p99', label: 'P99' },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: 'err',
|
|
101
|
+
type: 'line',
|
|
102
|
+
title: '错误率 (%)',
|
|
103
|
+
width: 'half',
|
|
104
|
+
series: [{ metric: 'err.rate', label: '错误率', scale: 100 }],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'kpi',
|
|
108
|
+
type: 'stat',
|
|
109
|
+
title: '关键指标',
|
|
110
|
+
width: 'full',
|
|
111
|
+
stats: [
|
|
112
|
+
{ metric: 'req.total', label: '总请求', agg: 'last' },
|
|
113
|
+
{ metric: 'req.errors', label: '错误数', agg: 'last' },
|
|
114
|
+
{ metric: 'lat.p95', label: 'P95 (ms)', agg: 'last' },
|
|
115
|
+
{
|
|
116
|
+
metric: 'err.rate', label: '错误率 (%)', agg: 'last', scale: 100,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: 'groups',
|
|
122
|
+
type: 'table',
|
|
123
|
+
title: '分接口',
|
|
124
|
+
width: 'full',
|
|
125
|
+
source: 'series',
|
|
126
|
+
metric: 'rps',
|
|
127
|
+
groupBy: 'group',
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ---------- 路径解析 ----------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 生成一次发压运行的运行键(开始时刻,秒级)。
|
|
136
|
+
*
|
|
137
|
+
* 秒级足够:同一任务串行执行(同项目串行红线),不可能出现同秒两次运行。
|
|
138
|
+
*/
|
|
139
|
+
export function new_run_key() {
|
|
140
|
+
return strftime_local(new Date(), RUN_KEY_FMT);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 指标通道文件路径(规范样本;与 runner 写入约定一致)。
|
|
145
|
+
*
|
|
146
|
+
* run_key 非空 → 按运行切分(`task_<id>_metrics_<运行键>.jsonl`);
|
|
147
|
+
* 为空 → 存量单文件路径 `task_<id>_metrics.jsonl`(旧任务读侧回落)。
|
|
148
|
+
*/
|
|
149
|
+
export function metrics_path(work_dir, task_id, run_key = '') {
|
|
150
|
+
const name = run_key
|
|
151
|
+
? `task_${task_id}_metrics_${run_key}.jsonl`
|
|
152
|
+
: `task_${task_id}_metrics.jsonl`;
|
|
153
|
+
return path.join(String(work_dir), '.web', name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 旧快照文件路径(场景驱动的兼容产物,同样按运行切分) */
|
|
157
|
+
export function snapshot_path(work_dir, task_id, run_key = '') {
|
|
158
|
+
const name = run_key ? `task_${task_id}_load_${run_key}.jsonl` : `task_${task_id}_load.jsonl`;
|
|
159
|
+
return path.join(String(work_dir), '.web', name);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 存量快照文件路径(旧任务;读侧归一后仍可出图) */
|
|
163
|
+
export function legacy_snapshot_path(work_dir, task_id) {
|
|
164
|
+
return path.join(String(work_dir), '.web', `task_${task_id}_load.jsonl`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 发压日志路径:按运行切分(每次运行一个文件),空运行键回落存量单文件 */
|
|
168
|
+
export function load_log_path(work_dir, task_id, run_key = '') {
|
|
169
|
+
const name = run_key ? `task_${task_id}_load_${run_key}.log` : `task_${task_id}_load.log`;
|
|
170
|
+
return path.join(String(work_dir), '.web', name);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 停止标志文件路径(脚本驱动:出现即请脚本优雅收尾) */
|
|
174
|
+
export function stop_file_path(work_dir, task_id) {
|
|
175
|
+
return path.join(String(work_dir), '.web', `task_${task_id}_load.stop`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 压测报告目录(json+md 落盘处) */
|
|
179
|
+
export function report_dir(cases_root) {
|
|
180
|
+
return path.join(String(cases_root), LOAD_DIR, 'report');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 报告文件主名(不带扩展名):`task<id>_<运行键>` */
|
|
184
|
+
export function report_stem(rep_dir, task_id, run_key) {
|
|
185
|
+
return path.join(String(rep_dir), `task${task_id}_${run_key}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Python `str.isdigit()`:Unicode「十进制数字(Nd)+ Digit 属性的 No」
|
|
189
|
+
const PY_DIGITS_RE = /^[\p{Nd}\p{No}]+$/u;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 报告文件名 → 运行键;无法解析(不属于该任务/名字畸形)返回 null。
|
|
193
|
+
*
|
|
194
|
+
* 新布局 `task<id>_YYYYmmdd_HHMMSS.json`(15 位时间戳,秒级);存量布局
|
|
195
|
+
* `task<id>_YYYYmmdd_HHMM.json`(13 位,分钟级,同分钟重跑会互相覆盖)。
|
|
196
|
+
*/
|
|
197
|
+
export function parse_report_run_key(name, task_id) {
|
|
198
|
+
const prefix = `task${task_id}_`;
|
|
199
|
+
const text = String(name);
|
|
200
|
+
if (!text.startsWith(prefix) || !text.endsWith('.json')) return null;
|
|
201
|
+
const key = text.slice(prefix.length, text.length - '.json'.length);
|
|
202
|
+
const parts = key.split('_');
|
|
203
|
+
if (parts.length !== 2 || !PY_DIGITS_RE.test(parts[0]) || !PY_DIGITS_RE.test(parts[1])) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if ([...parts[0]].length !== 8 || ![4, 6].includes([...parts[1]].length)) return null;
|
|
207
|
+
return key;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 列出该任务的全部报告(按运行键倒序,新的在前)。
|
|
212
|
+
*
|
|
213
|
+
* 返回 `[{run_key, json_path, md_path, mtime, legacy, has_md}]`:legacy=true 表示存量分钟级
|
|
214
|
+
* 命名(无法与运行行一一对应,读侧按「历史(未登记)」处理)。
|
|
215
|
+
*/
|
|
216
|
+
export function list_reports(cases_root, task_id) {
|
|
217
|
+
const repDir = report_dir(cases_root);
|
|
218
|
+
const out = [];
|
|
219
|
+
let names;
|
|
220
|
+
try {
|
|
221
|
+
names = fs.readdirSync(repDir);
|
|
222
|
+
} catch {
|
|
223
|
+
return out; // 照 Python:OSError → 空表
|
|
224
|
+
}
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
const runKey = parse_report_run_key(name, task_id);
|
|
227
|
+
if (runKey === null) continue;
|
|
228
|
+
const jsonPath = path.join(repDir, name);
|
|
229
|
+
const mdPath = `${jsonPath.slice(0, jsonPath.length - 5)}.md`;
|
|
230
|
+
let mtime = 0.0;
|
|
231
|
+
try {
|
|
232
|
+
mtime = pyMtimeFloat(fs.statSync(jsonPath, { bigint: true }).mtimeNs);
|
|
233
|
+
} catch {
|
|
234
|
+
mtime = 0.0;
|
|
235
|
+
}
|
|
236
|
+
out.push({
|
|
237
|
+
run_key: runKey,
|
|
238
|
+
json_path: jsonPath,
|
|
239
|
+
md_path: isFile(mdPath) ? mdPath : '',
|
|
240
|
+
mtime,
|
|
241
|
+
legacy: [...runKey.split('_')[1]].length === 4,
|
|
242
|
+
has_md: isFile(mdPath),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Python: out.sort(key=lambda r: (r["run_key"], r["mtime"]), reverse=True)
|
|
246
|
+
out.sort((a, b) => {
|
|
247
|
+
const c = pyStrCompare(a.run_key, b.run_key);
|
|
248
|
+
if (c !== 0) return -c;
|
|
249
|
+
return b.mtime - a.mtime;
|
|
250
|
+
});
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 方案包目录 `<案例库根>/load/task_<id>/` */
|
|
255
|
+
export function case_dir(cases_root, task_id) {
|
|
256
|
+
return path.join(String(cases_root), LOAD_DIR, `task_${task_id}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 解析压测方案包,返回驱动类型与各资产路径(四级回落)。
|
|
261
|
+
*
|
|
262
|
+
* 1. `load/task_<id>/run.py` → script(自定义脚本,平台托管执行)
|
|
263
|
+
* 2. `load/task_<id>/scenario.json` → scenario(平台调 loadgen CLI)
|
|
264
|
+
* 3. `load/scenario_<id>.json` → legacy_scenario(2026-09 前的旧布局)
|
|
265
|
+
* 4. 都没有 → driver=None(发压轮判失败)
|
|
266
|
+
*
|
|
267
|
+
* 返回 dict:driver / case_dir / plan_path / script_path / scenario_path /
|
|
268
|
+
* charts_path / html_path(不存在时对应值为 null)。
|
|
269
|
+
*/
|
|
270
|
+
export function resolve(cases_root, task_id) {
|
|
271
|
+
const cdir = case_dir(cases_root, task_id);
|
|
272
|
+
const script = path.join(cdir, SCRIPT_NAME);
|
|
273
|
+
const scenario = path.join(cdir, SCENARIO_NAME);
|
|
274
|
+
const legacy = path.join(String(cases_root), LOAD_DIR, `scenario_${task_id}.json`);
|
|
275
|
+
const plan = path.join(cdir, PLAN_NAME);
|
|
276
|
+
const charts = path.join(cdir, CHARTS_NAME);
|
|
277
|
+
const html = path.join(cdir, HTML_NAME);
|
|
278
|
+
let driver;
|
|
279
|
+
if (isFile(script)) driver = DRIVER_SCRIPT;
|
|
280
|
+
else if (isFile(scenario)) driver = DRIVER_SCENARIO;
|
|
281
|
+
else if (isFile(legacy)) driver = DRIVER_LEGACY;
|
|
282
|
+
else driver = null;
|
|
283
|
+
return {
|
|
284
|
+
driver,
|
|
285
|
+
case_dir: cdir,
|
|
286
|
+
plan_path: isFile(plan) ? plan : null,
|
|
287
|
+
script_path: driver === DRIVER_SCRIPT ? script : null,
|
|
288
|
+
scenario_path: driver === DRIVER_SCENARIO
|
|
289
|
+
? scenario
|
|
290
|
+
: (driver === DRIVER_LEGACY ? legacy : null),
|
|
291
|
+
charts_path: isFile(charts) ? charts : null,
|
|
292
|
+
html_path: isFile(html) ? html : null,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- 资产读取与图表声明校验 ----------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 读文本文件并按 ASSET_TEXT_MAX 截断(读不到返回空串)。
|
|
300
|
+
*
|
|
301
|
+
* 逐条对齐 `open(path, encoding="utf-8", errors="replace")` + `f.read(MAX+1)`:
|
|
302
|
+
* 只读 `(MAX+1)*4` 字节(UTF-8 每字符至多 4 字节,够 MAX+1 个**码点**)、通用换行
|
|
303
|
+
* (`\r\n`/`\r` → `\n`,Python 的 newline=None 同)、按码点切片。
|
|
304
|
+
*/
|
|
305
|
+
export function _read_text_capped(file_path) {
|
|
306
|
+
let buf;
|
|
307
|
+
try {
|
|
308
|
+
const fd = fs.openSync(file_path, 'r');
|
|
309
|
+
try {
|
|
310
|
+
const cap = (ASSET_TEXT_MAX + 1) * 4;
|
|
311
|
+
const tmp = Buffer.allocUnsafe(cap);
|
|
312
|
+
const n = fs.readSync(fd, tmp, 0, cap, 0);
|
|
313
|
+
buf = tmp.subarray(0, n);
|
|
314
|
+
} finally {
|
|
315
|
+
fs.closeSync(fd);
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
return ''; // 照 Python:吞 OSError(含 IsADirectoryError)
|
|
319
|
+
}
|
|
320
|
+
const text = buf.toString('utf8').replace(/\r\n?/g, '\n');
|
|
321
|
+
const chars = [...text];
|
|
322
|
+
if (chars.length > ASSET_TEXT_MAX) {
|
|
323
|
+
return `${chars.slice(0, ASSET_TEXT_MAX).join('')}\n\n…(内容过长已截断)`;
|
|
324
|
+
}
|
|
325
|
+
return text;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 校验折线面板的 series 数组;返回 `[规范化的 series, 错误说明]` */
|
|
329
|
+
export function _validate_series(series, err_prefix) {
|
|
330
|
+
if (!Array.isArray(series) || !(series.length >= 1 && series.length <= MAX_SERIES)) {
|
|
331
|
+
return [null, `${err_prefix}.series 必须是 1~${MAX_SERIES} 项数组`];
|
|
332
|
+
}
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const s of series) {
|
|
335
|
+
if (!(s !== null && typeof s === 'object' && !Array.isArray(s))
|
|
336
|
+
|| !pyStrip(pyStr(_get(s, 'metric', '')))) {
|
|
337
|
+
return [null, `${err_prefix}.series[].metric 必填`];
|
|
338
|
+
}
|
|
339
|
+
const item = { metric: pyStrip(pyStr(s.metric)) };
|
|
340
|
+
if (pyTruthy(_get(s, 'label', null))) item.label = pyStr(s.label);
|
|
341
|
+
const match = _get(s, 'match', null);
|
|
342
|
+
if (match !== null) {
|
|
343
|
+
if (!(typeof match === 'object' && !Array.isArray(match))) {
|
|
344
|
+
return [null, `${err_prefix}.series[].match 必须是对象`];
|
|
345
|
+
}
|
|
346
|
+
const kept = {};
|
|
347
|
+
for (const [k, v] of Object.entries(match)) {
|
|
348
|
+
const scalar = typeof v === 'string' || typeof v === 'number';
|
|
349
|
+
if (typeof v === 'boolean' || !scalar) continue;
|
|
350
|
+
kept[pyStr(k)] = v;
|
|
351
|
+
}
|
|
352
|
+
item.match = kept;
|
|
353
|
+
}
|
|
354
|
+
if (_isScalarNum(_get(s, 'scale', null))) item.scale = py_float(s.scale);
|
|
355
|
+
out.push(item);
|
|
356
|
+
}
|
|
357
|
+
return [out, null];
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Python `isinstance(x, (int, float)) and not isinstance(x, bool)` */
|
|
361
|
+
function _isScalarNum(value) {
|
|
362
|
+
return typeof value === 'number';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Python `dict.get(k[, d])`(只有键不存在才给默认值;防原型链上的同名键) */
|
|
366
|
+
function _get(obj, key, fallback = null) {
|
|
367
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) return obj[key];
|
|
368
|
+
return fallback;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 校验单值卡面板的指标项(单 metric 形式归一为 stats 数组) */
|
|
372
|
+
export function _validate_stat_items(panel, err_prefix) {
|
|
373
|
+
let raw = _get(panel, 'stats', null);
|
|
374
|
+
if (raw === null) {
|
|
375
|
+
if (!pyStrip(pyStr(_get(panel, 'metric', '')))) {
|
|
376
|
+
return [null, `${err_prefix} 需要 metric 或 stats`];
|
|
377
|
+
}
|
|
378
|
+
raw = [{
|
|
379
|
+
metric: panel.metric,
|
|
380
|
+
label: _get(panel, 'label', null),
|
|
381
|
+
agg: _get(panel, 'agg', null),
|
|
382
|
+
unit: _get(panel, 'unit', null),
|
|
383
|
+
scale: _get(panel, 'scale', null),
|
|
384
|
+
}];
|
|
385
|
+
}
|
|
386
|
+
if (!Array.isArray(raw) || !(raw.length >= 1 && raw.length <= MAX_STATS)) {
|
|
387
|
+
return [null, `${err_prefix}.stats 必须是 1~${MAX_STATS} 项数组`];
|
|
388
|
+
}
|
|
389
|
+
const out = [];
|
|
390
|
+
for (const it of raw) {
|
|
391
|
+
if (!(it !== null && typeof it === 'object' && !Array.isArray(it))
|
|
392
|
+
|| !pyStrip(pyStr(_get(it, 'metric', '')))) {
|
|
393
|
+
return [null, `${err_prefix}.stats[].metric 必填`];
|
|
394
|
+
}
|
|
395
|
+
const item = {
|
|
396
|
+
metric: pyStrip(pyStr(it.metric)),
|
|
397
|
+
label: pyStr(pyTruthy(_get(it, 'label', null)) ? it.label : it.metric),
|
|
398
|
+
};
|
|
399
|
+
const agg = pyStr(pyTruthy(_get(it, 'agg', null)) ? it.agg : 'last');
|
|
400
|
+
if (!['last', 'avg', 'max', 'min', 'sum'].includes(agg)) {
|
|
401
|
+
return [null, `${err_prefix}.stats[].agg 非法: ${agg}`];
|
|
402
|
+
}
|
|
403
|
+
item.agg = agg;
|
|
404
|
+
for (const key of ['unit', 'hint']) {
|
|
405
|
+
if (pyTruthy(_get(it, key, null))) item[key] = pyStr(it[key]);
|
|
406
|
+
}
|
|
407
|
+
if (_isScalarNum(_get(it, 'scale', null))) item.scale = py_float(it.scale);
|
|
408
|
+
out.push(item);
|
|
409
|
+
}
|
|
410
|
+
return [out, null];
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* 校验图表声明 charts.json;返回 `[规范化 charts, null]` 或 `[null, 错误说明]`。
|
|
415
|
+
*
|
|
416
|
+
* 规则从宽但足够严:类型/必填字段/条目数越界即整份判非法(调用方回退默认面板),
|
|
417
|
+
* 单个面板的可选字段缺失一律补默认值,避免前端再做一层容错。
|
|
418
|
+
*/
|
|
419
|
+
export function validate_charts(obj) {
|
|
420
|
+
if (!(obj !== null && typeof obj === 'object' && !Array.isArray(obj))) {
|
|
421
|
+
return [null, 'charts.json 顶层必须是 JSON 对象'];
|
|
422
|
+
}
|
|
423
|
+
const panels = _get(obj, 'panels', null);
|
|
424
|
+
if (!Array.isArray(panels) || !(panels.length >= 1 && panels.length <= MAX_PANELS)) {
|
|
425
|
+
return [null, `panels 必须是 1~${MAX_PANELS} 项数组`];
|
|
426
|
+
}
|
|
427
|
+
const outPanels = [];
|
|
428
|
+
const seen = new Set();
|
|
429
|
+
for (let i = 0; i < panels.length; i += 1) {
|
|
430
|
+
const p = panels[i];
|
|
431
|
+
const tag = `panels[${i}]`;
|
|
432
|
+
if (!(p !== null && typeof p === 'object' && !Array.isArray(p))) {
|
|
433
|
+
return [null, `${tag} 必须是对象`];
|
|
434
|
+
}
|
|
435
|
+
const ptype = pyStrip(pyStr(_get(p, 'type', '')));
|
|
436
|
+
if (!PANEL_TYPES.includes(ptype)) {
|
|
437
|
+
return [null, `${tag}.type 非法: ${ptype || '(空)'}`];
|
|
438
|
+
}
|
|
439
|
+
const pid = pyStrip(pyStr(_get(p, 'id', '')));
|
|
440
|
+
if (!pid) return [null, `${tag}.id 必填`];
|
|
441
|
+
if (seen.has(pid)) return [null, `${tag}.id 重复: ${pid}`];
|
|
442
|
+
seen.add(pid);
|
|
443
|
+
const item = { id: pid, type: ptype, title: pyStr(pyTruthy(_get(p, 'title', null)) ? p.title : pid) };
|
|
444
|
+
const width = pyStr(pyTruthy(_get(p, 'width', null)) ? p.width : 'half');
|
|
445
|
+
if (!PANEL_WIDTHS.includes(width)) return [null, `${tag}.width 非法: ${width}`];
|
|
446
|
+
item.width = width;
|
|
447
|
+
if (pyTruthy(_get(p, 'hint', null))) item.hint = pyStr(p.hint);
|
|
448
|
+
if (ptype === 'line' || ptype === 'bar') {
|
|
449
|
+
if (ptype === 'line') {
|
|
450
|
+
const [series, err] = _validate_series(_get(p, 'series', null), tag);
|
|
451
|
+
if (err) return [null, err];
|
|
452
|
+
item.series = series;
|
|
453
|
+
} else {
|
|
454
|
+
if (!pyStrip(pyStr(_get(p, 'metric', '')))) return [null, `${tag}.metric 必填`];
|
|
455
|
+
item.metric = pyStrip(pyStr(p.metric));
|
|
456
|
+
const by = pyStr(pyTruthy(_get(p, 'by', null)) ? p.by : 'time');
|
|
457
|
+
if (!['time', 'label'].includes(by)) return [null, `${tag}.by 非法: ${by}`];
|
|
458
|
+
item.by = by;
|
|
459
|
+
if (pyTruthy(_get(p, 'groupBy', null))) item.groupBy = pyStr(p.groupBy);
|
|
460
|
+
}
|
|
461
|
+
} else if (ptype === 'stacked_bar' || ptype === 'donut') {
|
|
462
|
+
if (!pyStrip(pyStr(_get(p, 'metric', '')))) return [null, `${tag}.metric 必填`];
|
|
463
|
+
if (!pyStrip(pyStr(_get(p, 'groupBy', '')))) return [null, `${tag}.groupBy 必填`];
|
|
464
|
+
item.metric = pyStrip(pyStr(p.metric));
|
|
465
|
+
item.groupBy = pyStrip(pyStr(p.groupBy));
|
|
466
|
+
} else if (ptype === 'stat') {
|
|
467
|
+
const [stats, err] = _validate_stat_items(p, tag);
|
|
468
|
+
if (err) return [null, err];
|
|
469
|
+
item.stats = stats;
|
|
470
|
+
} else if (ptype === 'table') {
|
|
471
|
+
const src = pyStr(pyTruthy(_get(p, 'source', null)) ? p.source : 'series');
|
|
472
|
+
if (!['series', 'summary'].includes(src)) return [null, `${tag}.source 非法: ${src}`];
|
|
473
|
+
item.source = src;
|
|
474
|
+
if (src === 'series' && !pyStrip(pyStr(_get(p, 'metric', '')))) {
|
|
475
|
+
return [null, `${tag}.metric 必填(source=series)`];
|
|
476
|
+
}
|
|
477
|
+
if (pyTruthy(_get(p, 'metric', null))) item.metric = pyStrip(pyStr(p.metric));
|
|
478
|
+
if (pyTruthy(_get(p, 'groupBy', null))) item.groupBy = pyStr(p.groupBy);
|
|
479
|
+
}
|
|
480
|
+
outPanels.push(item);
|
|
481
|
+
}
|
|
482
|
+
const charts = {
|
|
483
|
+
version: 1,
|
|
484
|
+
title: pyStr(pyTruthy(_get(obj, 'title', null)) ? obj.title : '压测指标'),
|
|
485
|
+
panels: outPanels,
|
|
486
|
+
};
|
|
487
|
+
return [charts, null];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 读取并校验图表声明;返回 `[charts, 错误说明]`。
|
|
492
|
+
*
|
|
493
|
+
* path 为空/读不到/JSON 非法/校验不过 → 一律回落 `DEFAULT_CHARTS`(**同一个对象**,
|
|
494
|
+
* 照 Python 返回共享常量),并把原因放在第二个返回值(供任务日志记一行,不阻断发压)。
|
|
495
|
+
*/
|
|
496
|
+
export function load_charts(chart_path) {
|
|
497
|
+
if (!pyTruthy(chart_path)) return [DEFAULT_CHARTS, ''];
|
|
498
|
+
let obj;
|
|
499
|
+
try {
|
|
500
|
+
obj = read_json_file(chart_path);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
if (e instanceof ValueError) {
|
|
503
|
+
return [DEFAULT_CHARTS, `图表声明不是合法 JSON: ${e && e.message !== undefined ? e.message : e}`];
|
|
504
|
+
}
|
|
505
|
+
if (e && typeof e.code === 'string' && typeof e.syscall === 'string') {
|
|
506
|
+
return [DEFAULT_CHARTS, `图表声明读取失败: ${py_error_str(e, chart_path)}`];
|
|
507
|
+
}
|
|
508
|
+
throw e;
|
|
509
|
+
}
|
|
510
|
+
const [charts, err] = validate_charts(obj);
|
|
511
|
+
if (err) return [DEFAULT_CHARTS, err];
|
|
512
|
+
return [charts, ''];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 读取方案包资产(供面板「压测方案」页与图表渲染使用)。
|
|
517
|
+
*
|
|
518
|
+
* 返回 dict:found(是否有执行载体)/ driver / plan_md / plan_found /
|
|
519
|
+
* script_name / script_text / charts / charts_error / has_html / case_dir / html_path。
|
|
520
|
+
*/
|
|
521
|
+
export function read_assets(cases_root, task_id) {
|
|
522
|
+
const info = resolve(cases_root, task_id);
|
|
523
|
+
const [charts, chartsErr] = load_charts(info.charts_path);
|
|
524
|
+
let scriptName = '';
|
|
525
|
+
let scriptText = '';
|
|
526
|
+
if (info.script_path) {
|
|
527
|
+
scriptName = SCRIPT_NAME;
|
|
528
|
+
scriptText = _read_text_capped(info.script_path);
|
|
529
|
+
} else if (info.scenario_path) {
|
|
530
|
+
scriptName = pyBasename(info.scenario_path);
|
|
531
|
+
scriptText = _read_text_capped(info.scenario_path);
|
|
532
|
+
}
|
|
533
|
+
const planMd = info.plan_path ? _read_text_capped(info.plan_path) : '';
|
|
534
|
+
return {
|
|
535
|
+
found: info.driver !== null,
|
|
536
|
+
driver: info.driver || '',
|
|
537
|
+
plan_md: planMd,
|
|
538
|
+
plan_found: pyTruthy(planMd),
|
|
539
|
+
script_name: scriptName,
|
|
540
|
+
script_text: scriptText,
|
|
541
|
+
charts,
|
|
542
|
+
charts_error: chartsErr,
|
|
543
|
+
has_html: pyTruthy(info.html_path),
|
|
544
|
+
case_dir: info.case_dir,
|
|
545
|
+
html_path: info.html_path,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------- 指标行归一 ----------
|
|
550
|
+
|
|
551
|
+
/** 构造一条规范样本(值统一转 float,标签统一转 str→标量) */
|
|
552
|
+
export function _sample(ts, metric, value, mtype = 'gauge', labels = null) {
|
|
553
|
+
const s = {
|
|
554
|
+
ts, metric, value, type: mtype,
|
|
555
|
+
};
|
|
556
|
+
if (pyTruthy(labels)) {
|
|
557
|
+
const out = {};
|
|
558
|
+
for (const [k, v] of Object.entries(labels)) out[pyStr(k)] = v;
|
|
559
|
+
s.labels = out;
|
|
560
|
+
}
|
|
561
|
+
return s;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* 旧快照行 → 规范样本列表。
|
|
566
|
+
*
|
|
567
|
+
* 旧行形如 `{"ts","stage","conc","rps","err","groups":{名:{"rps","err","lat"}}}`,
|
|
568
|
+
* 只有当秒窗口计数与分组延迟分桶,没有逐请求样本:分位数沿用旧前端口径
|
|
569
|
+
* (跨组合并分桶后插值近似,当秒窗口的 P95 抖动属正常)。
|
|
570
|
+
*/
|
|
571
|
+
export function _legacy_samples(obj) {
|
|
572
|
+
const ts = pyTruthy(_get(obj, 'ts', null)) ? obj.ts : Math.trunc(Date.now() / 1000);
|
|
573
|
+
const rps = py_float(pyTruthy(_get(obj, 'rps', null)) ? obj.rps : 0.0);
|
|
574
|
+
const err = py_float(pyTruthy(_get(obj, 'err', null)) ? obj.err : 0.0);
|
|
575
|
+
const out = [
|
|
576
|
+
_sample(ts, 'rps', rps),
|
|
577
|
+
_sample(ts, 'err.rate', pyTruthy(rps) ? err / rps : 0.0),
|
|
578
|
+
_sample(ts, 'conc', pyInt(pyTruthy(_get(obj, 'conc', null)) ? obj.conc : 0)),
|
|
579
|
+
];
|
|
580
|
+
const groups = pyTruthy(_get(obj, 'groups', null)) ? obj.groups : {};
|
|
581
|
+
const merged = {};
|
|
582
|
+
for (const [name, g] of Object.entries(groups)) {
|
|
583
|
+
if (!(g !== null && typeof g === 'object' && !Array.isArray(g))) continue;
|
|
584
|
+
const buckets = pyTruthy(_get(g, 'lat', null)) ? g.lat : {};
|
|
585
|
+
for (const [b, c] of Object.entries(buckets)) {
|
|
586
|
+
merged[b] = pyGetNum(merged, b) + c;
|
|
587
|
+
}
|
|
588
|
+
const gRps = py_float(pyTruthy(_get(g, 'rps', null)) ? g.rps : 0.0);
|
|
589
|
+
const gErr = py_float(pyTruthy(_get(g, 'err', null)) ? g.err : 0.0);
|
|
590
|
+
out.push(_sample(ts, 'rps', gRps, 'gauge', { group: name }));
|
|
591
|
+
out.push(_sample(ts, 'err.rate', pyTruthy(gRps) ? gErr / gRps : 0.0, 'gauge', { group: name }));
|
|
592
|
+
const p95 = _pct_from_buckets(buckets, 95);
|
|
593
|
+
if (p95 !== null) out.push(_sample(ts, 'lat.p95', p95, 'gauge', { group: name }));
|
|
594
|
+
}
|
|
595
|
+
for (const pct of [50, 90, 99]) {
|
|
596
|
+
const val = _pct_from_buckets(merged, pct);
|
|
597
|
+
if (val !== null) out.push(_sample(ts, `lat.p${pct}`, val));
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** `merged.get(b, 0)`(累加器里的键一定是我们自己放的字符串) */
|
|
603
|
+
function pyGetNum(obj, key) {
|
|
604
|
+
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : 0;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* 指标文件一行 → 规范样本列表(新格式原样透传,旧快照行映射)。
|
|
609
|
+
*
|
|
610
|
+
* 控制行(meta/summary)原样返回,由前端按 kind 分支处理;非 dict 返回空列表。
|
|
611
|
+
*/
|
|
612
|
+
export function normalize_line(obj) {
|
|
613
|
+
if (!(obj !== null && typeof obj === 'object' && !Array.isArray(obj))) return [];
|
|
614
|
+
const kind = _get(obj, 'kind', null);
|
|
615
|
+
if (kind === 'meta' || kind === 'summary') return [obj];
|
|
616
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'metric')) return [obj];
|
|
617
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'rps')
|
|
618
|
+
&& Object.prototype.hasOwnProperty.call(obj, 'groups')) {
|
|
619
|
+
return _legacy_samples(obj);
|
|
620
|
+
}
|
|
621
|
+
return [];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 读取指标文件的最后一条 summary 行;无则返回 {}。
|
|
626
|
+
*
|
|
627
|
+
* 压测文件可能很大,按 64KiB 分块**流式**扫描(`StringDecoder` 处理跨块的多字节字符),
|
|
628
|
+
* 不整体载入。
|
|
629
|
+
*/
|
|
630
|
+
export function read_summary(file_path) {
|
|
631
|
+
let found = {};
|
|
632
|
+
let fd;
|
|
633
|
+
try {
|
|
634
|
+
fd = fs.openSync(file_path, 'r');
|
|
635
|
+
} catch {
|
|
636
|
+
return {}; // 照 Python:OSError → {}
|
|
637
|
+
}
|
|
638
|
+
const decoder = new StringDecoder('utf8');
|
|
639
|
+
const buf = Buffer.allocUnsafe(65536);
|
|
640
|
+
let carry = '';
|
|
641
|
+
const eat = (line) => {
|
|
642
|
+
const raw = pyStrip(line);
|
|
643
|
+
if (!raw || !raw.includes('"summary"')) return;
|
|
644
|
+
let obj;
|
|
645
|
+
try {
|
|
646
|
+
obj = JSON.parse(raw);
|
|
647
|
+
} catch {
|
|
648
|
+
return; // 照 Python:ValueError → 跳过该行
|
|
649
|
+
}
|
|
650
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)
|
|
651
|
+
&& _get(obj, 'kind', null) === 'summary') {
|
|
652
|
+
const vals = _get(obj, 'values', null);
|
|
653
|
+
if (vals !== null && typeof vals === 'object' && !Array.isArray(vals)) found = vals;
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
try {
|
|
657
|
+
for (;;) {
|
|
658
|
+
const n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
659
|
+
if (!n) break;
|
|
660
|
+
carry += decoder.write(buf.subarray(0, n));
|
|
661
|
+
let idx;
|
|
662
|
+
while ((idx = carry.indexOf('\n')) >= 0) {
|
|
663
|
+
eat(carry.slice(0, idx));
|
|
664
|
+
carry = carry.slice(idx + 1);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
carry += decoder.end();
|
|
668
|
+
if (carry) eat(carry);
|
|
669
|
+
} catch (e) {
|
|
670
|
+
if (e && typeof e.code === 'string') return {}; // 读中途 OSError:照 Python 整体吞掉
|
|
671
|
+
throw e;
|
|
672
|
+
} finally {
|
|
673
|
+
try {
|
|
674
|
+
fs.closeSync(fd);
|
|
675
|
+
} catch { /* 已关 */ }
|
|
676
|
+
}
|
|
677
|
+
return found;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ---------- 脚本驱动的最小报告 ----------
|
|
681
|
+
|
|
682
|
+
/** 脚本驱动报告 Markdown:总览 KPI 表(无指标数据时给出提示) */
|
|
683
|
+
export function _minimal_md(report) {
|
|
684
|
+
const lines = [`# 压测报告:${report.case}`, '',
|
|
685
|
+
`- 驱动:自定义脚本(${report.driver})`,
|
|
686
|
+
`- 开始:${report.started_at} · 时长 ${py_field_str(report.duration_sec)}s`
|
|
687
|
+
+ (pyTruthy(_get(report, 'stopped', null)) ? '(用户停止,部分结果)' : ''), ''];
|
|
688
|
+
const values = pyTruthy(_get(report, 'summary', null)) ? report.summary : {};
|
|
689
|
+
if (pyTruthy(values)) {
|
|
690
|
+
lines.push('## 关键指标', '', '| 指标 | 值 |', '|---|---|');
|
|
691
|
+
for (const [k, v] of Object.entries(values)) lines.push(`| ${k} | ${py_field_str(v)} |`);
|
|
692
|
+
} else {
|
|
693
|
+
lines.push('本次压测未上报收尾指标(summary 行缺失);实时曲线请见面板「压测日志」页。');
|
|
694
|
+
}
|
|
695
|
+
lines.push('');
|
|
696
|
+
return lines.join('\n');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* 脚本驱动的报告落盘:`<rep_dir>/task<id>_<运行键>.{json,md}`。
|
|
701
|
+
*
|
|
702
|
+
* 与 `loadgen.write_report_files` 同名同目录,读侧按运行键/最新取用,不区分驱动。
|
|
703
|
+
* run_key 为空时回落分钟级命名(存量路径与单测用)。返回 `[json_path, md_path]`。
|
|
704
|
+
*/
|
|
705
|
+
export function write_minimal_report(payload, rep_dir, task_id, run_key = '') {
|
|
706
|
+
fs.mkdirSync(rep_dir, { recursive: true });
|
|
707
|
+
const stem = report_stem(rep_dir, task_id, run_key || strftime_local(new Date(), '%Y%m%d_%H%M'));
|
|
708
|
+
fs.writeFileSync(`${stem}.json`, pyJsonDumpsIndent(payload, 1), 'utf8');
|
|
709
|
+
fs.writeFileSync(`${stem}.md`, _minimal_md(payload), 'utf8');
|
|
710
|
+
return [`${stem}.json`, `${stem}.md`];
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** summary values → 一行摘要文本(rounds.summary 用,保持 LOAD 前缀) */
|
|
714
|
+
export function summarize_values(values) {
|
|
715
|
+
if (!pyTruthy(values)) return 'LOAD 无指标数据';
|
|
716
|
+
const parts = Object.entries(values).slice(0, 8)
|
|
717
|
+
.map(([k, v]) => `LOAD ${k}=${py_field_str(v)}`);
|
|
718
|
+
return parts.join(' ');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// --------------------------------------------------------------------------
|
|
722
|
+
// camelCase 别名(JS 调用方用;量具按 Python 名反射,用不到这一套)
|
|
723
|
+
// --------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
export {
|
|
726
|
+
new_run_key as newRunKey,
|
|
727
|
+
metrics_path as metricsPath,
|
|
728
|
+
snapshot_path as snapshotPath,
|
|
729
|
+
legacy_snapshot_path as legacySnapshotPath,
|
|
730
|
+
load_log_path as loadLogPath,
|
|
731
|
+
stop_file_path as stopFilePath,
|
|
732
|
+
report_dir as reportDir,
|
|
733
|
+
report_stem as reportStem,
|
|
734
|
+
parse_report_run_key as parseReportRunKey,
|
|
735
|
+
list_reports as listReports,
|
|
736
|
+
case_dir as caseDir,
|
|
737
|
+
validate_charts as validateCharts,
|
|
738
|
+
load_charts as loadCharts,
|
|
739
|
+
read_assets as readAssets,
|
|
740
|
+
normalize_line as normalizeLine,
|
|
741
|
+
read_summary as readSummary,
|
|
742
|
+
write_minimal_report as writeMinimalReport,
|
|
743
|
+
summarize_values as summarizeValues,
|
|
744
|
+
_read_text_capped as _readTextCapped,
|
|
745
|
+
_validate_series as _validateSeries,
|
|
746
|
+
_validate_stat_items as _validateStatItems,
|
|
747
|
+
_legacy_samples as _legacySamples,
|
|
748
|
+
_minimal_md as _minimalMd,
|
|
749
|
+
};
|