@yuhere/js-jsonrpc2 2.1.8 → 2.1.10
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 +10 -2
- package/SKILL.md +21 -3
- package/dist/core/Repository.d.ts +1 -1
- package/dist/core/Repository.d.ts.map +1 -1
- package/dist/core/Repository.js +3 -12
- package/dist/core/Repository.js.map +1 -1
- package/dist/core/llm-tools.d.ts +3 -2
- package/dist/core/llm-tools.d.ts.map +1 -1
- package/dist/core/llm-tools.js +43 -3
- package/dist/core/llm-tools.js.map +1 -1
- package/htdoc/assets/{index-BAZmvr5x.css → index-Ck-39Igh.css} +221 -0
- package/htdoc/assets/{index-sVQlhxjI.js → index-Cr0Jnrgb.js} +716 -35
- package/htdoc/assets/index-Cr0Jnrgb.js.map +1 -0
- package/htdoc/index.html +2 -2
- package/package.json +3 -3
- package/webui/src/App.tsx +101 -20
- package/webui/src/api/glob.ts +90 -0
- package/webui/src/api/metrics.ts +135 -0
- package/webui/src/api/paramForm.ts +4 -3
- package/webui/src/components/MetricsPanel.md +126 -0
- package/webui/src/components/MetricsPanel.tsx +290 -0
- package/webui/src/components/SchemaForm.tsx +29 -27
- package/webui/src/components/SchemaView.tsx +3 -3
- package/webui/src/hooks/useMetrics.ts +91 -0
- package/webui/src/styles/geek.css +221 -0
- package/htdoc/assets/index-sVQlhxjI.js.map +0 -1
package/htdoc/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>JSON-RPC 2.0</title>
|
|
7
|
-
<script type="module" crossorigin src="./assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
7
|
+
<script type="module" crossorigin src="./assets/index-Cr0Jnrgb.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Ck-39Igh.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yuhere/js-jsonrpc2",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.10",
|
|
4
4
|
"description": "A TypeScript JSON-RPC 2.0 server library with schema validation, authorization, dependency injection, and multiple transports.",
|
|
5
5
|
"homepage": "https://github.com/yuhere/js-jsonrpc2#readme",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "https://github.com/yuhere/js-jsonrpc2.git"
|
|
26
|
+
"url": "git+https://github.com/yuhere/js-jsonrpc2.git"
|
|
27
27
|
},
|
|
28
28
|
"type": "module",
|
|
29
29
|
"main": "./dist/index.cjs",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"./package.json": "./package.json"
|
|
38
38
|
},
|
|
39
39
|
"bin": {
|
|
40
|
-
"js-jsonrpc2": "
|
|
40
|
+
"js-jsonrpc2": "dist/cli/cli.js"
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"build": "npm run clean && npm run build:lib && npm run build:webui",
|
package/webui/src/App.tsx
CHANGED
|
@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
2
2
|
import { createRpc, type TransportKind } from './api/rpc';
|
|
3
3
|
import { useMethods } from './hooks/useMethods';
|
|
4
4
|
import { useMethodDetail } from './hooks/useMethodDetail';
|
|
5
|
+
import { useMetrics } from './hooks/useMetrics';
|
|
5
6
|
import { Toolbar } from './components/Toolbar';
|
|
6
7
|
import { MethodTree } from './components/MethodTree';
|
|
7
8
|
import { MethodDetail } from './components/MethodDetail';
|
|
8
9
|
import { TestPanel } from './components/TestPanel';
|
|
10
|
+
import { MetricsPanel } from './components/MetricsPanel';
|
|
11
|
+
import { compileGlobFilter, filterSnapshotMethods } from './api/glob';
|
|
12
|
+
import type { MetricsSnapshot } from './api/metrics';
|
|
9
13
|
import {
|
|
10
14
|
collectApiDoc,
|
|
11
15
|
downloadText,
|
|
@@ -13,22 +17,34 @@ import {
|
|
|
13
17
|
} from './api/docGen';
|
|
14
18
|
|
|
15
19
|
const ENDPOINT = '';
|
|
20
|
+
type View = 'methods' | 'metrics';
|
|
16
21
|
|
|
17
22
|
export default function App() {
|
|
18
23
|
const [transport, setTransport] = useState<TransportKind>('http');
|
|
19
24
|
const [selected, setSelected] = useState('');
|
|
20
25
|
const [generatingDocs, setGeneratingDocs] = useState(false);
|
|
26
|
+
const [view, setView] = useState<View>('methods');
|
|
27
|
+
const [methodFilter, setMethodFilter] = useState('');
|
|
21
28
|
|
|
22
|
-
const rpc = useMemo(() => {
|
|
23
|
-
return createRpc({ transport, endpoint: ENDPOINT });
|
|
24
|
-
}, [transport]);
|
|
29
|
+
const rpc = useMemo(() => createRpc({ transport, endpoint: ENDPOINT }), [transport]);
|
|
25
30
|
|
|
26
|
-
useEffect(() =>
|
|
27
|
-
return () => rpc.close();
|
|
28
|
-
}, [rpc]);
|
|
31
|
+
useEffect(() => () => rpc.close(), [rpc]);
|
|
29
32
|
|
|
30
33
|
const { methods, loading, error, refresh } = useMethods(rpc);
|
|
31
34
|
const detail = useMethodDetail(rpc, selected);
|
|
35
|
+
const metrics = useMetrics(rpc, view === 'metrics');
|
|
36
|
+
|
|
37
|
+
const compiledFilter = useMemo(() => compileGlobFilter(methodFilter), [methodFilter]);
|
|
38
|
+
|
|
39
|
+
// 过滤后的快照:不改变拉取范围,只裁剪 methods。
|
|
40
|
+
const filteredSnapshot = useMemo<MetricsSnapshot | undefined>(() => {
|
|
41
|
+
if (!metrics.snapshot) return undefined;
|
|
42
|
+
if (compiledFilter.passthrough) return metrics.snapshot;
|
|
43
|
+
return {
|
|
44
|
+
...metrics.snapshot,
|
|
45
|
+
methods: filterSnapshotMethods(compiledFilter, metrics.snapshot.methods) as MetricsSnapshot['methods'],
|
|
46
|
+
};
|
|
47
|
+
}, [metrics.snapshot, compiledFilter]);
|
|
32
48
|
|
|
33
49
|
const generateDocs = useCallback(async () => {
|
|
34
50
|
if (methods.length === 0) {
|
|
@@ -39,10 +55,7 @@ export default function App() {
|
|
|
39
55
|
try {
|
|
40
56
|
const doc = await collectApiDoc(rpc, methods);
|
|
41
57
|
const md = renderMarkdown(doc);
|
|
42
|
-
const ts = new Date()
|
|
43
|
-
.toISOString()
|
|
44
|
-
.replace(/[:.]/g, '-')
|
|
45
|
-
.slice(0, 19);
|
|
58
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
46
59
|
downloadText(`jsonrpc-api-doc-${ts}.md`, md, 'text/markdown');
|
|
47
60
|
} catch (e) {
|
|
48
61
|
alert(`生成文档失败:${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -64,18 +77,86 @@ export default function App() {
|
|
|
64
77
|
/>
|
|
65
78
|
<main>
|
|
66
79
|
<aside className="sidebar">
|
|
67
|
-
<
|
|
68
|
-
|
|
80
|
+
<div className="view-switch segmented" role="group" aria-label="视图">
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
className={view === 'methods' ? 'active' : ''}
|
|
84
|
+
onClick={() => setView('methods')}
|
|
85
|
+
>
|
|
86
|
+
Methods
|
|
87
|
+
</button>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className={view === 'metrics' ? 'active' : ''}
|
|
91
|
+
onClick={() => setView('metrics')}
|
|
92
|
+
>
|
|
93
|
+
Metrics
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{view === 'methods' ? (
|
|
98
|
+
<>
|
|
99
|
+
<h2>Methods</h2>
|
|
100
|
+
<MethodTree methods={methods} selected={selected} onSelect={setSelected} />
|
|
101
|
+
</>
|
|
102
|
+
) : (
|
|
103
|
+
<div className="metrics-sidebar">
|
|
104
|
+
<h2>Metrics</h2>
|
|
105
|
+
<p className="hint" style={{ margin: '0 0 8px' }}>
|
|
106
|
+
📊 服务端 RPC 调用统计。
|
|
107
|
+
</p>
|
|
108
|
+
<label className="metrics-filter">
|
|
109
|
+
<span className="metrics-filter-label">Filter</span>
|
|
110
|
+
<input
|
|
111
|
+
className="sf-input"
|
|
112
|
+
type="text"
|
|
113
|
+
placeholder="user.*, !system.*"
|
|
114
|
+
value={methodFilter}
|
|
115
|
+
onChange={(e) => setMethodFilter(e.target.value)}
|
|
116
|
+
spellCheck={false}
|
|
117
|
+
/>
|
|
118
|
+
</label>
|
|
119
|
+
<p className="hint metrics-filter-help">
|
|
120
|
+
支持 <code>*</code> / <code>?</code> / <code>!</code>,多个模式用 <code>,</code> 分隔。
|
|
121
|
+
<br />
|
|
122
|
+
例:<code>user.*</code>、<code>*.get</code>、<code>!system.*</code>、<code>user.*,!user.secret</code>
|
|
123
|
+
</p>
|
|
124
|
+
{metrics.snapshot && (
|
|
125
|
+
<p className="hint metrics-filter-stat">
|
|
126
|
+
匹配 {Object.keys(filteredSnapshot?.methods ?? {}).length} /{' '}
|
|
127
|
+
{Object.keys(metrics.snapshot.methods).length} 个方法
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
69
132
|
</aside>
|
|
70
133
|
<div className="content">
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
134
|
+
{view === 'methods' ? (
|
|
135
|
+
<>
|
|
136
|
+
<MethodDetail method={selected} detail={detail} />
|
|
137
|
+
<TestPanel
|
|
138
|
+
method={selected}
|
|
139
|
+
detail={detail}
|
|
140
|
+
rpc={rpc}
|
|
141
|
+
transport={transport}
|
|
142
|
+
endpoint={ENDPOINT}
|
|
143
|
+
/>
|
|
144
|
+
</>
|
|
145
|
+
) : (
|
|
146
|
+
<MetricsPanel
|
|
147
|
+
snapshot={filteredSnapshot}
|
|
148
|
+
previous={metrics.previous}
|
|
149
|
+
fetchedAt={metrics.fetchedAt}
|
|
150
|
+
loading={metrics.loading}
|
|
151
|
+
error={metrics.error}
|
|
152
|
+
available={metrics.available}
|
|
153
|
+
onRefresh={() => void metrics.refresh()}
|
|
154
|
+
autoRefresh={metrics.autoRefresh}
|
|
155
|
+
onAutoRefreshChange={metrics.setAutoRefresh}
|
|
156
|
+
intervalMs={metrics.intervalMs}
|
|
157
|
+
onIntervalChange={metrics.setIntervalMs}
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
79
160
|
</div>
|
|
80
161
|
</main>
|
|
81
162
|
</div>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 极简 glob 匹配 + 多模式过滤。
|
|
3
|
+
*
|
|
4
|
+
* 语法:
|
|
5
|
+
* - `*` 任意字符(含 `.`)
|
|
6
|
+
* - `?` 单个字符
|
|
7
|
+
* - `**` 与 `*` 等价(兼容常见 glob 方言)
|
|
8
|
+
* - `!pat` 取反;多个 `!` 之间是「且」
|
|
9
|
+
* - 多个模式用 `,` 分隔;正向模式之间是「或」,取反模式与整体是「且」
|
|
10
|
+
* - 空串或纯空白 → 不过滤
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface CompiledFilter {
|
|
14
|
+
/** 正向模式(OR);为空表示「全部」。 */
|
|
15
|
+
includes: RegExp[];
|
|
16
|
+
/** 取反模式(AND NOT)。 */
|
|
17
|
+
excludes: RegExp[];
|
|
18
|
+
/** 原始输入,便于回显。 */
|
|
19
|
+
raw: string;
|
|
20
|
+
/** 是否等价于「不过滤」。 */
|
|
21
|
+
passthrough: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function globToRegExp(glob: string): RegExp {
|
|
25
|
+
let re = '^';
|
|
26
|
+
for (let i = 0; i < glob.length; i++) {
|
|
27
|
+
const ch = glob[i]!;
|
|
28
|
+
if (ch === '*') {
|
|
29
|
+
// 吞掉连续 *(** 视为 *)
|
|
30
|
+
while (i + 1 < glob.length && glob[i + 1] === '*') i++;
|
|
31
|
+
re += '.*';
|
|
32
|
+
} else if (ch === '?') {
|
|
33
|
+
re += '.';
|
|
34
|
+
} else if ('\\^$+{}()|[]'.includes(ch)) {
|
|
35
|
+
re += '\\' + ch;
|
|
36
|
+
} else {
|
|
37
|
+
re += ch;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
re += '$';
|
|
41
|
+
return new RegExp(re);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function compileGlobFilter(input: string): CompiledFilter {
|
|
45
|
+
const raw = input ?? '';
|
|
46
|
+
const trimmed = raw.trim();
|
|
47
|
+
if (trimmed.length === 0) {
|
|
48
|
+
return { includes: [], excludes: [], raw, passthrough: true };
|
|
49
|
+
}
|
|
50
|
+
const includes: RegExp[] = [];
|
|
51
|
+
const excludes: RegExp[] = [];
|
|
52
|
+
for (const part of trimmed.split(',')) {
|
|
53
|
+
const pat = part.trim();
|
|
54
|
+
if (pat.length === 0) continue;
|
|
55
|
+
if (pat.startsWith('!')) {
|
|
56
|
+
const body = pat.slice(1).trim();
|
|
57
|
+
if (body.length > 0) excludes.push(globToRegExp(body));
|
|
58
|
+
} else {
|
|
59
|
+
includes.push(globToRegExp(pat));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// 只有 !pat 时,includes 为空 → 视为「从全部里排除」
|
|
63
|
+
return { includes, excludes, raw, passthrough: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function matchMethod(filter: CompiledFilter, method: string): boolean {
|
|
67
|
+
if (filter.passthrough) return true;
|
|
68
|
+
if (filter.excludes.some((re) => re.test(method))) return false;
|
|
69
|
+
if (filter.includes.length === 0) return true; // 只有 !pat 时,保留所有未排除的
|
|
70
|
+
return filter.includes.some((re) => re.test(method));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 对一组 method 名做过滤;返回过滤后的列表(保持原顺序)。 */
|
|
74
|
+
export function filterMethods<T extends string>(filter: CompiledFilter, methods: T[]): T[] {
|
|
75
|
+
if (filter.passthrough) return methods;
|
|
76
|
+
return methods.filter((m) => matchMethod(filter, m));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 便捷:直接对 MetricsSnapshot 的 methods 做过滤,返回浅拷贝。 */
|
|
80
|
+
export function filterSnapshotMethods(
|
|
81
|
+
filter: CompiledFilter,
|
|
82
|
+
methods: Record<string, unknown>,
|
|
83
|
+
): Record<string, unknown> {
|
|
84
|
+
if (filter.passthrough) return methods;
|
|
85
|
+
const out: Record<string, unknown> = {};
|
|
86
|
+
for (const [method, stats] of Object.entries(methods)) {
|
|
87
|
+
if (matchMethod(filter, method)) out[method] = stats;
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { RpcClient } from './rpc';
|
|
2
|
+
|
|
3
|
+
// ─── 与后端 memory.ts 对齐的类型(只读快照) ─────────────────
|
|
4
|
+
|
|
5
|
+
export interface HistogramSnapshot {
|
|
6
|
+
count: number;
|
|
7
|
+
sum: number;
|
|
8
|
+
min: number;
|
|
9
|
+
max: number;
|
|
10
|
+
buckets: Array<{ le: number; count: number }>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StreamStats {
|
|
14
|
+
opened: number;
|
|
15
|
+
completed: number;
|
|
16
|
+
failed: number;
|
|
17
|
+
failedByPhase: Record<string, number>;
|
|
18
|
+
aborted: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MethodStats {
|
|
22
|
+
calls: number;
|
|
23
|
+
callsByStatus: Record<string, number>;
|
|
24
|
+
errorsByCode: Record<string, number>;
|
|
25
|
+
duration: HistogramSnapshot;
|
|
26
|
+
streams: StreamStats;
|
|
27
|
+
streamDuration: HistogramSnapshot;
|
|
28
|
+
streamTimeToFirstChunk: HistogramSnapshot;
|
|
29
|
+
streamChunksTotal: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MetricsSnapshot {
|
|
33
|
+
uptimeMs: number;
|
|
34
|
+
methods: Record<string, MethodStats>;
|
|
35
|
+
invalidRequests: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MetricsFetchResult {
|
|
39
|
+
/** 最近一次成功拉取的快照。 */
|
|
40
|
+
snapshot: MetricsSnapshot;
|
|
41
|
+
/** 拉取时刻(本地时钟,用于计算速率)。 */
|
|
42
|
+
fetchedAt: number;
|
|
43
|
+
/** 上一份快照,用于计算「间隔内的增量」。 */
|
|
44
|
+
previous?: {
|
|
45
|
+
snapshot: MetricsSnapshot;
|
|
46
|
+
fetchedAt: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 拉取 system.metrics;如果系统方法不可用(withSystemMethods=false),返回 undefined。 */
|
|
51
|
+
export async function fetchMetrics(rpc: RpcClient): Promise<MetricsSnapshot | undefined> {
|
|
52
|
+
try {
|
|
53
|
+
const raw = await rpc.call<MetricsSnapshot>('system.metrics');
|
|
54
|
+
if (!raw || typeof raw !== 'object' || typeof raw.methods !== 'object') return undefined;
|
|
55
|
+
return raw;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── 派生计算 ───────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** 在给定直方图上估算某个分位数(线性插值)。 */
|
|
64
|
+
export function quantileFromHistogram(h: HistogramSnapshot, q: number): number {
|
|
65
|
+
if (h.count === 0) return 0;
|
|
66
|
+
const target = q * h.count;
|
|
67
|
+
let prevLe = 0;
|
|
68
|
+
let prevCount = 0;
|
|
69
|
+
for (const b of h.buckets) {
|
|
70
|
+
if (b.le === Number.POSITIVE_INFINITY) {
|
|
71
|
+
// +Inf 桶:用 max 作为上界估计
|
|
72
|
+
const lo = prevLe;
|
|
73
|
+
const hi = h.max;
|
|
74
|
+
const span = b.count - prevCount || 1;
|
|
75
|
+
const ratio = (target - prevCount) / span;
|
|
76
|
+
return lo + (hi - lo) * ratio;
|
|
77
|
+
}
|
|
78
|
+
if (b.count >= target) {
|
|
79
|
+
const lo = prevLe;
|
|
80
|
+
const hi = b.le;
|
|
81
|
+
const span = b.count - prevCount || 1;
|
|
82
|
+
const ratio = (target - prevCount) / span;
|
|
83
|
+
return lo + (hi - lo) * ratio;
|
|
84
|
+
}
|
|
85
|
+
prevLe = b.le;
|
|
86
|
+
prevCount = b.count;
|
|
87
|
+
}
|
|
88
|
+
return h.max;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface MethodDerived {
|
|
92
|
+
method: string;
|
|
93
|
+
stats: MethodStats;
|
|
94
|
+
/** 调用速率(次/秒),仅当有 previous 时有意义。 */
|
|
95
|
+
callsPerSecond?: number;
|
|
96
|
+
/** 错误率 = errors / calls(按 callsByStatus 汇总)。 */
|
|
97
|
+
errorRate: number;
|
|
98
|
+
/** 普通调用 p50/p95/p99(秒)。 */
|
|
99
|
+
p50: number;
|
|
100
|
+
p95: number;
|
|
101
|
+
p99: number;
|
|
102
|
+
/** 流式:完成率 = completed / opened;中止率 = aborted / opened。 */
|
|
103
|
+
streamCompletionRate: number;
|
|
104
|
+
streamAbortRate: number;
|
|
105
|
+
/** 流式首帧 p50/p95(秒)。 */
|
|
106
|
+
ttfbP50: number;
|
|
107
|
+
ttfbP95: number;
|
|
108
|
+
/** 流式平均帧数。 */
|
|
109
|
+
avgChunksPerStream: number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function deriveMethod(method: string, stats: MethodStats, prev?: MethodStats, dtSeconds?: number): MethodDerived {
|
|
113
|
+
const totalErrors = Object.values(stats.errorsByCode).reduce((a, b) => a + b, 0);
|
|
114
|
+
const opened = stats.streams.opened || 0;
|
|
115
|
+
const errorRate = stats.calls > 0 ? totalErrors / stats.calls : 0;
|
|
116
|
+
const callsPerSecond =
|
|
117
|
+
prev && dtSeconds && dtSeconds > 0
|
|
118
|
+
? Math.max(0, (stats.calls - prev.calls) / dtSeconds)
|
|
119
|
+
: undefined;
|
|
120
|
+
return {
|
|
121
|
+
method,
|
|
122
|
+
stats,
|
|
123
|
+
...(callsPerSecond !== undefined ? { callsPerSecond } : {}),
|
|
124
|
+
errorRate,
|
|
125
|
+
p50: quantileFromHistogram(stats.duration, 0.5),
|
|
126
|
+
p95: quantileFromHistogram(stats.duration, 0.95),
|
|
127
|
+
p99: quantileFromHistogram(stats.duration, 0.99),
|
|
128
|
+
streamCompletionRate: opened > 0 ? stats.streams.completed / opened : 0,
|
|
129
|
+
streamAbortRate: opened > 0 ? stats.streams.aborted / opened : 0,
|
|
130
|
+
ttfbP50: quantileFromHistogram(stats.streamTimeToFirstChunk, 0.5),
|
|
131
|
+
ttfbP95: quantileFromHistogram(stats.streamTimeToFirstChunk, 0.95),
|
|
132
|
+
avgChunksPerStream:
|
|
133
|
+
opened > 0 ? stats.streamChunksTotal / (stats.streams.completed + stats.streams.failed + stats.streams.aborted || 1) : 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -7,10 +7,11 @@ export function isSchema(v: unknown): v is SchemaDescription {
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* 根据 schema 生成一个合理的示例值。
|
|
10
|
+
* - nullable 优先返回 null
|
|
11
|
+
* - null 直接返回 null
|
|
10
12
|
* - 原始类型返回零值(''、0、false)
|
|
11
13
|
* - literal 返回固定值
|
|
12
14
|
* - enum 返回第一个枚举值
|
|
13
|
-
* - nullable 返回 null
|
|
14
15
|
* - 容器类型递归展开
|
|
15
16
|
* - unknown 返回 null
|
|
16
17
|
*/
|
|
@@ -24,14 +25,14 @@ export function sampleFromSchema(schema: SchemaDescription): unknown {
|
|
|
24
25
|
return 0;
|
|
25
26
|
case 'boolean':
|
|
26
27
|
return false;
|
|
28
|
+
case 'null':
|
|
29
|
+
return null;
|
|
27
30
|
case 'literal':
|
|
28
31
|
return schema.constraints?.value ?? null;
|
|
29
32
|
case 'enum': {
|
|
30
33
|
const values = schema.constraints?.values;
|
|
31
34
|
return Array.isArray(values) && values.length > 0 ? values[0] : '';
|
|
32
35
|
}
|
|
33
|
-
case 'nullable':
|
|
34
|
-
return isSchema(c.inner) ? sampleFromSchema(c.inner) : null;
|
|
35
36
|
case 'array': {
|
|
36
37
|
const item = isSchema(c.items) ? sampleFromSchema(c.items) : null;
|
|
37
38
|
return [item];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Metrics 表格列说明
|
|
2
|
+
|
|
3
|
+
两张表都来自 `MetricsPanel.tsx`,数据源自服务端 `system.metrics` 返回的 `MetricsSnapshot`(`memory.ts` 聚合)。下表的「口径」指该列在 `memory.ts` / `deriveMethod` 中的计算方式。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Non-stream methods
|
|
8
|
+
|
|
9
|
+
出现在该表的方法:`stats.streams.opened === 0`,即**从未产生过流式调用**的方法。列顺序:
|
|
10
|
+
|
|
11
|
+
| # | 列名 | 含义 | 口径 | 空/零值显示 |
|
|
12
|
+
|---|---|---|---|---|
|
|
13
|
+
| 1 | `method` | 方法 namespace | 直接来自 `snapshot.methods` 的 key | — |
|
|
14
|
+
| 2 | `calls` | 调用总次数 | `stats.calls`,每次 `onCallEnd` +1(含 `stream_opened` / `stream_error`,但本表只含 `opened === 0` 的方法,所以等价于 `success + error`) | `0` |
|
|
15
|
+
| 3 | `rate/s` | 调用速率(次/秒) | `(calls − previous.calls) / (fetchedAt − previous.fetchedAt)`,由两次快照差值得到 | 首次拉取 / 无 `previous` 时显示 `—` |
|
|
16
|
+
| 4 | `errors` | 错误总数 | `sum(errorsByCode)`,即 `RPCFailure` 的累计次数(含 `-32602` / `-32604` / `-32603` 等) | `0` |
|
|
17
|
+
| 5 | `err%` | 错误率 | `errors / calls`;`calls === 0` 时为 0 | `—`(`calls === 0` 时) |
|
|
18
|
+
| 6 | `p50` | 耗时中位数 | 对 `stats.duration` 直方图做线性插值,`quantileFromHistogram(h, 0.5)` | `—`(`duration.count === 0`) |
|
|
19
|
+
| 7 | `p95` | 耗时 p95 | `quantileFromHistogram(h, 0.95)` | `—` |
|
|
20
|
+
| 8 | `p99` | 耗时 p99 | `quantileFromHistogram(h, 0.99)` | `—` |
|
|
21
|
+
| 9 | `avg` | 平均耗时 | `duration.sum / duration.count`;`count === 0` 时为 0 | `—`(`count === 0`) |
|
|
22
|
+
|
|
23
|
+
补充:
|
|
24
|
+
|
|
25
|
+
- **`calls` 与 `errors` 的关系**:`errors` 是 `calls` 的子集。一次调用失败时 `onCallEnd(status: 'error', code)` 让 `calls +1` 且 `errorsByCode[code] +1`;一次成功调用只让 `calls +1`。所以 `calls ≥ errors`,`err% ≤ 100%`。
|
|
26
|
+
- **`calls` 与 `duration.count` 的关系**:`duration` 只在 `status ∈ {success, error}` 时 `observe`,而本表的方法 `opened === 0`,没有 `stream_opened` / `stream_error` 路径,所以理论上 `duration.count === calls`。若两者不等(例如未来 `memory.ts` 引入新的 status),`avg` 仍按 `duration.count` 计算,与 p50/p95/p99 同源。
|
|
27
|
+
- **`rate/s` 的 `—` 是暂时的**:第二次拉取后(`previous` 存在)立即显示数值。切换 glob 过滤器不会重置 `previous`,速率保持连续。
|
|
28
|
+
- **颜色**:`errors > 0` 与 `err% > 5%` 标红(`--danger`);`rate/s > 100` 标红、`> 10` 标黄(`--syn-number`)、其余灰(`--fg-dim`),仅视觉提示。
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Stream methods
|
|
33
|
+
|
|
34
|
+
出现在该表的方法:`stats.streams.opened > 0`,即**至少被以流式方式调用过一次**的方法。列顺序:
|
|
35
|
+
|
|
36
|
+
| # | 列名 | 含义 | 口径 | 空/零值显示 |
|
|
37
|
+
|---|---|---|---|---|
|
|
38
|
+
| 1 | `method` | 方法 namespace | 直接来自 `snapshot.methods` 的 key | — |
|
|
39
|
+
| 2 | `opened` | 流打开次数 | `streams.opened`,每次 `onStreamEvent({ event: 'opened' })` +1;与 `onCallEnd(stream_opened)` 一一对应 | `0` |
|
|
40
|
+
| 3 | `completed` | 正常结束次数 | `streams.completed`,收到终止帧 `{ done: true }` 且无 error 时 +1 | `0` |
|
|
41
|
+
| 4 | `failed` | 失败次数 | `streams.failed`,`onStreamEvent({ event: 'failed' })`;含 `before_first_chunk` 与 `mid_stream` 两种 phase | `0`(> 0 时标红) |
|
|
42
|
+
| 5 | `aborted` | 客户端中止次数 | `streams.aborted`,`finally` 里既无 `completed` 也无 `failure` 时 +1(客户端断开触发 `chunks.return()`) | `0`(> 0 时标黄) |
|
|
43
|
+
| 6 | `complete%` | 完成率 | `streams.completed / streams.opened` | `—`(`opened === 0` 时,但本表不会出现) |
|
|
44
|
+
| 7 | `abort%` | 中止率 | `streams.aborted / streams.opened` | `—`(同上) |
|
|
45
|
+
| 8 | `avg chunks` | 平均帧数 | `streamChunksTotal / (completed + failed + aborted)`;分母为 0 时取 1 避免除零 | `0.0` |
|
|
46
|
+
| 9 | `ttfb p50` | 首帧延迟中位数 | `quantileFromHistogram(streamTimeToFirstChunk, 0.5)`;`ttfb` 只在首帧到达时记录 | `—`(`ttfb.count === 0`) |
|
|
47
|
+
| 10 | `ttfb p95` | 首帧延迟 p95 | `quantileFromHistogram(streamTimeToFirstChunk, 0.95)` | `—` |
|
|
48
|
+
| 11 | `duration avg` | 流总时长平均值 | `streamDuration.sum / streamDuration.count`;时长从 `onStreamEvent(opened)` 到 `completed`/`failed`/`aborted` | `—`(`count === 0`) |
|
|
49
|
+
|
|
50
|
+
补充:
|
|
51
|
+
|
|
52
|
+
- **`opened` 与 `completed + failed + aborted` 的关系**:正常情况下相等。若 UI 轮询恰好落在「已 opened 但尚未收到终止事件」的窗口内,会短暂出现 `opened > completed + failed + aborted`,下一次拉取即恢复。
|
|
53
|
+
- **`failed` 的两种 phase**:`before_first_chunk`(首帧前抛错,服务端会额外补一次 `onCallEnd(stream_error)`,因此也会让 Non-stream 表的 `calls` 增加——但该方法一旦进过 Stream 表就不会再回 Non-stream 表,所以不影响本表口径);`mid_stream`(首帧后抛错,只通过终止帧表达,不计入 Non-stream 的 `calls`/`errors`)。
|
|
54
|
+
- **`ttfb` 与 `duration` 的口径差异**:`ttfb` 是「首帧到达相对 `opened` 的延迟」,`duration` 是「整个流的时长」。无限流(`stream.infinite`)的 `duration` 会很大,但 `ttfb` 仍很小;两者分开看才能判断是「启动慢」还是「流太长」。
|
|
55
|
+
- **`aborted` 的语义**:客户端主动断开(关闭页面、`fetch` abort、`break` 出 `for await`)会让 transport 调 `chunks.return()`,触发 handler 的 `finally`。此时 `onStreamEvent({ event: 'aborted' })` 记录一次。如果 `aborted` 持续增长而 `completed` 不增长,通常意味着客户端超时或 SPA 路由切换导致的中断,而不是服务端 bug。
|
|
56
|
+
- **颜色**:`failed > 0` 标红,`aborted > 0` 标黄,与 Non-stream 表的错误/警告配色一致。
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 两张表共享的约定
|
|
61
|
+
|
|
62
|
+
- **耗时单位**:`formatDuration` 自动选单位——`< 1ms` 用 µs,`< 1s` 用 ms,`< 60s` 用 s,≥ 60s 用 min。表格里不显示单位后缀以外的信息,排序仍按原始秒数。
|
|
63
|
+
- **分位数估算**:`quantileFromHistogram` 在固定桶边界上做线性插值,`+Inf` 桶用 `max` 作为上界。桶边界由 `memory.ts` 的 `DURATION_BUCKETS` / `STREAM_DURATION_BUCKETS` / `TTFB_BUCKETS` 定义,UI 不需要知道边界值。
|
|
64
|
+
- **过滤影响**:左侧 glob 过滤器只裁剪 `snapshot.methods`,两张表与顶部卡片(`Methods` / `Calls` / `Streams` / `Errors` / `Error rate`)都只反映匹配的方法;`Invalid requests` 是全局计数,不随过滤变化。
|
|
65
|
+
- **排序**:两张表都按 `calls` 降序(`deriveMethod` 之后统一 `sort`);Stream 表的 `calls` 含 `stream_opened`,所以排序结果与 `opened` 趋势基本一致。
|
|
66
|
+
- **表格不区分 `system.` / `llm.`**:`system.metrics`、`system.listMethods` 等会出现在 Non-stream 表;如果它们曾经以流式被调用(目前不会),会出现在 Stream 表。分组只看 `streams.opened`。
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
## p95 / p99 是什么
|
|
70
|
+
|
|
71
|
+
**p95 / p99 是「百分位数(percentile)」**,用来描述延迟分布,而不是平均值。
|
|
72
|
+
|
|
73
|
+
### 定义
|
|
74
|
+
|
|
75
|
+
- **p50(中位数)**:50% 的请求耗时 ≤ 这个值。
|
|
76
|
+
- **p95**:95% 的请求耗时 ≤ 这个值,剩下 5% 更慢。
|
|
77
|
+
- **p99**:99% 的请求耗时 ≤ 这个值,剩下 1% 更慢。
|
|
78
|
+
|
|
79
|
+
举例:某方法 1000 次调用,耗时排序后:
|
|
80
|
+
|
|
81
|
+
| 指标 | 值 | 含义 |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| p50 | 12 ms | 一半请求在 12ms 内完成 |
|
|
84
|
+
| p95 | 180 ms | 95% 的请求在 180ms 内完成 |
|
|
85
|
+
| p99 | 950 ms | 99% 的请求在 950ms 内完成,最慢的 1% 超过 950ms |
|
|
86
|
+
| avg | 45 ms | 平均值 |
|
|
87
|
+
|
|
88
|
+
### 为什么不能只看 avg
|
|
89
|
+
|
|
90
|
+
平均值会被两头拉平:
|
|
91
|
+
|
|
92
|
+
- 少数极慢请求会把 avg 抬高,但看不出「到底有多慢」。
|
|
93
|
+
- 大量快请求会把 avg 压低,掩盖尾部延迟。
|
|
94
|
+
|
|
95
|
+
比如上面的例子,avg 只有 45ms,看起来很快;但 p99 是 950ms——每 100 个用户里就有 1 个要等将近 1 秒。这种「长尾」在平均值里几乎看不见,在 p99 里一目了然。
|
|
96
|
+
|
|
97
|
+
### 为什么是 p95 / p99,不是 p100
|
|
98
|
+
|
|
99
|
+
- **p100 = max**,完全由单个最慢请求决定,噪声极大(一次 GC、一次网络抖动就能把它打到几秒),不适合作为稳定指标。
|
|
100
|
+
- **p95** 用来衡量「大多数用户体验」。
|
|
101
|
+
- **p99** 用来衡量「尾部体验」——这正是用户抱怨、SLO 违约、告警最常触发的地方。
|
|
102
|
+
|
|
103
|
+
业界常见的 SLO 写法就是基于分位数,例如「p99 延迟 < 500ms」「p95 < 200ms」。
|
|
104
|
+
|
|
105
|
+
### 它们和 p50 一起看才有意义
|
|
106
|
+
|
|
107
|
+
| 组合 | 说明 |
|
|
108
|
+
|---|---|
|
|
109
|
+
| p50 低、p99 低 | 整体快且稳定 |
|
|
110
|
+
| p50 低、p99 高 | 大多数快,但有长尾(常见于锁竞争、GC、下游超时) |
|
|
111
|
+
| p50 高、p99 高 | 整体慢(容量不足、下游普遍慢) |
|
|
112
|
+
| p50 高、p99 接近 p50 | 慢但稳定(固定开销大,如冷启动、串行化) |
|
|
113
|
+
|
|
114
|
+
### 在这个 Metrics 面板里怎么来的
|
|
115
|
+
|
|
116
|
+
服务端 `memory.ts` 不保存每次调用的精确耗时,只维护一个**固定桶的直方图**(`DURATION_BUCKETS = [0.001, 0.005, 0.01, 0.025, ...]`)。前端 `quantileFromHistogram` 在桶边界之间做**线性插值**估算 p50/p95/p99。
|
|
117
|
+
|
|
118
|
+
这意味着:
|
|
119
|
+
|
|
120
|
+
- 数值是**近似值**,误差受桶密度影响(桶越密越准)。
|
|
121
|
+
- 与真实分位数的偏差通常在桶宽的同一量级内,足够判断「是否健康」,不适合做精确计费或 SLA 赔付。
|
|
122
|
+
- 如果需要精确分位数,后端应改用 t-digest / HDR Histogram 之类的结构,前端只需替换 `quantileFromHistogram` 的实现,表格列不变。
|
|
123
|
+
|
|
124
|
+
### 一句话
|
|
125
|
+
|
|
126
|
+
> p95 / p99 = 「95% / 99% 的请求比它快」的延迟阈值。它们回答的是「大多数用户等多久」和「最慢的那批用户等多久」,这是平均值回答不了的问题。
|