@yuhere/js-jsonrpc2 2.1.9 → 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/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
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
deriveMethod,
|
|
4
|
+
type MethodDerived,
|
|
5
|
+
type MetricsSnapshot,
|
|
6
|
+
} from '../api/metrics';
|
|
7
|
+
|
|
8
|
+
interface MetricsPanelProps {
|
|
9
|
+
snapshot: MetricsSnapshot | undefined;
|
|
10
|
+
previous: { snapshot: MetricsSnapshot; fetchedAt: number } | undefined;
|
|
11
|
+
fetchedAt: number | undefined;
|
|
12
|
+
loading: boolean;
|
|
13
|
+
error: string | null;
|
|
14
|
+
available: boolean;
|
|
15
|
+
onRefresh: () => void;
|
|
16
|
+
autoRefresh: boolean;
|
|
17
|
+
onAutoRefreshChange: (v: boolean) => void;
|
|
18
|
+
intervalMs: number;
|
|
19
|
+
onIntervalChange: (v: number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatDuration(seconds: number): string {
|
|
23
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return '—';
|
|
24
|
+
if (seconds < 0.001) return `${(seconds * 1e6).toFixed(0)} µs`;
|
|
25
|
+
if (seconds < 1) return `${(seconds * 1000).toFixed(1)} ms`;
|
|
26
|
+
if (seconds < 60) return `${seconds.toFixed(2)} s`;
|
|
27
|
+
return `${(seconds / 60).toFixed(1)} min`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatRate(v: number | undefined): string {
|
|
31
|
+
if (v === undefined) return '—';
|
|
32
|
+
if (v >= 100) return v.toFixed(0);
|
|
33
|
+
if (v >= 10) return v.toFixed(1);
|
|
34
|
+
return v.toFixed(2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatPercent(v: number): string {
|
|
38
|
+
if (!Number.isFinite(v)) return '—';
|
|
39
|
+
return `${(v * 100).toFixed(1)}%`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function rateClass(v: number | undefined): string {
|
|
43
|
+
if (v === undefined) return '';
|
|
44
|
+
if (v > 100) return 'rate-hot';
|
|
45
|
+
if (v > 10) return 'rate-warm';
|
|
46
|
+
return 'rate-cold';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function MetricsPanel({
|
|
50
|
+
snapshot,
|
|
51
|
+
previous,
|
|
52
|
+
fetchedAt,
|
|
53
|
+
loading,
|
|
54
|
+
error,
|
|
55
|
+
available,
|
|
56
|
+
onRefresh,
|
|
57
|
+
autoRefresh,
|
|
58
|
+
onAutoRefreshChange,
|
|
59
|
+
intervalMs,
|
|
60
|
+
onIntervalChange,
|
|
61
|
+
}: MetricsPanelProps) {
|
|
62
|
+
const derived = useMemo<MethodDerived[]>(() => {
|
|
63
|
+
if (!snapshot) return [];
|
|
64
|
+
const dtSeconds =
|
|
65
|
+
previous && fetchedAt
|
|
66
|
+
? (fetchedAt - previous.fetchedAt) / 1000
|
|
67
|
+
: undefined;
|
|
68
|
+
const rows: MethodDerived[] = [];
|
|
69
|
+
for (const [method, stats] of Object.entries(snapshot.methods)) {
|
|
70
|
+
const prev = previous?.snapshot.methods[method];
|
|
71
|
+
rows.push(deriveMethod(method, stats, prev, dtSeconds));
|
|
72
|
+
}
|
|
73
|
+
rows.sort((a, b) => b.stats.calls - a.stats.calls);
|
|
74
|
+
return rows;
|
|
75
|
+
}, [snapshot, previous, fetchedAt]);
|
|
76
|
+
|
|
77
|
+
// 只按「是否产生过流式调用」分组;一旦 opened>0 即视为 stream method。
|
|
78
|
+
const streamMethods = derived.filter((r) => r.stats.streams.opened > 0);
|
|
79
|
+
const nonStreamMethods = derived.filter((r) => r.stats.streams.opened === 0);
|
|
80
|
+
|
|
81
|
+
const totals = useMemo(() => {
|
|
82
|
+
if (!snapshot) return { calls: 0, errors: 0, methods: 0, streams: 0 };
|
|
83
|
+
let calls = 0;
|
|
84
|
+
let errors = 0;
|
|
85
|
+
let streams = 0;
|
|
86
|
+
for (const s of Object.values(snapshot.methods)) {
|
|
87
|
+
calls += s.calls;
|
|
88
|
+
errors += Object.values(s.errorsByCode).reduce((a, b) => a + b, 0);
|
|
89
|
+
streams += s.streams.opened;
|
|
90
|
+
}
|
|
91
|
+
return { calls, errors, methods: Object.keys(snapshot.methods).length, streams };
|
|
92
|
+
}, [snapshot]);
|
|
93
|
+
|
|
94
|
+
if (!available) {
|
|
95
|
+
return (
|
|
96
|
+
<section className="metrics">
|
|
97
|
+
<h2>Metrics</h2>
|
|
98
|
+
<p className="hint">
|
|
99
|
+
服务端未启用 <code>system.metrics</code>(<code>withSystemMethods: false</code> 或未注册)。
|
|
100
|
+
</p>
|
|
101
|
+
</section>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<section className="metrics">
|
|
107
|
+
<header className="metrics-header">
|
|
108
|
+
<h2>Metrics</h2>
|
|
109
|
+
<div className="metrics-controls">
|
|
110
|
+
<label className="metrics-auto">
|
|
111
|
+
<input
|
|
112
|
+
type="checkbox"
|
|
113
|
+
checked={autoRefresh}
|
|
114
|
+
onChange={(e) => onAutoRefreshChange(e.target.checked)}
|
|
115
|
+
/>
|
|
116
|
+
自动刷新
|
|
117
|
+
</label>
|
|
118
|
+
<select
|
|
119
|
+
className="sf-input"
|
|
120
|
+
value={intervalMs}
|
|
121
|
+
onChange={(e) => onIntervalChange(Number(e.target.value))}
|
|
122
|
+
disabled={!autoRefresh}
|
|
123
|
+
title="刷新间隔"
|
|
124
|
+
>
|
|
125
|
+
<option value={1000}>1s</option>
|
|
126
|
+
<option value={3000}>3s</option>
|
|
127
|
+
<option value={5000}>5s</option>
|
|
128
|
+
<option value={10000}>10s</option>
|
|
129
|
+
</select>
|
|
130
|
+
<button onClick={onRefresh} disabled={loading}>
|
|
131
|
+
{loading ? '刷新中…' : '立即刷新'}
|
|
132
|
+
</button>
|
|
133
|
+
{snapshot && (
|
|
134
|
+
<span className="hint">
|
|
135
|
+
uptime {formatDuration(snapshot.uptimeMs / 1000)}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
</header>
|
|
140
|
+
|
|
141
|
+
{error && <p className="error">{error}</p>}
|
|
142
|
+
{!snapshot && !error && <p className="hint">加载中…</p>}
|
|
143
|
+
|
|
144
|
+
{snapshot && (
|
|
145
|
+
<>
|
|
146
|
+
<div className="metrics-cards">
|
|
147
|
+
<MetricCard label="Methods" value={String(totals.methods)} />
|
|
148
|
+
<MetricCard label="Calls" value={String(totals.calls)} />
|
|
149
|
+
<MetricCard label="Streams" value={String(totals.streams)} />
|
|
150
|
+
<MetricCard
|
|
151
|
+
label="Errors"
|
|
152
|
+
value={String(totals.errors)}
|
|
153
|
+
tone={totals.errors > 0 ? 'danger' : undefined}
|
|
154
|
+
/>
|
|
155
|
+
<MetricCard
|
|
156
|
+
label="Error rate"
|
|
157
|
+
value={totals.calls > 0 ? formatPercent(totals.errors / totals.calls) : '—'}
|
|
158
|
+
tone={totals.calls > 0 && totals.errors / totals.calls > 0.05 ? 'danger' : undefined}
|
|
159
|
+
/>
|
|
160
|
+
<MetricCard
|
|
161
|
+
label="Invalid requests"
|
|
162
|
+
value={String(snapshot.invalidRequests)}
|
|
163
|
+
tone={snapshot.invalidRequests > 0 ? 'warn' : undefined}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<h3>
|
|
168
|
+
Non-stream methods
|
|
169
|
+
<span className="hint"> ({nonStreamMethods.length})</span>
|
|
170
|
+
</h3>
|
|
171
|
+
{nonStreamMethods.length === 0 ? (
|
|
172
|
+
<p className="hint">暂无数据。</p>
|
|
173
|
+
) : (
|
|
174
|
+
<NonStreamTable rows={nonStreamMethods} />
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
<h3>
|
|
178
|
+
Stream methods
|
|
179
|
+
<span className="hint"> ({streamMethods.length})</span>
|
|
180
|
+
</h3>
|
|
181
|
+
{streamMethods.length === 0 ? (
|
|
182
|
+
<p className="hint">暂无流式调用。</p>
|
|
183
|
+
) : (
|
|
184
|
+
<StreamTable rows={streamMethods} />
|
|
185
|
+
)}
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
</section>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: 'danger' | 'warn' }) {
|
|
193
|
+
return (
|
|
194
|
+
<div className={`metric-card${tone ? ` metric-card-${tone}` : ''}`}>
|
|
195
|
+
<div className="metric-card-label">{label}</div>
|
|
196
|
+
<div className="metric-card-value">{value}</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function NonStreamTable({ rows }: { rows: MethodDerived[] }) {
|
|
202
|
+
return (
|
|
203
|
+
<table className="metrics-table">
|
|
204
|
+
<thead>
|
|
205
|
+
<tr>
|
|
206
|
+
<th>method</th>
|
|
207
|
+
<th className="num">calls</th>
|
|
208
|
+
<th className="num">rate/s</th>
|
|
209
|
+
<th className="num">errors</th>
|
|
210
|
+
<th className="num">err%</th>
|
|
211
|
+
<th className="num">p50</th>
|
|
212
|
+
<th className="num">p95</th>
|
|
213
|
+
<th className="num">p99</th>
|
|
214
|
+
<th className="num">avg</th>
|
|
215
|
+
</tr>
|
|
216
|
+
</thead>
|
|
217
|
+
<tbody>
|
|
218
|
+
{rows.map((r) => {
|
|
219
|
+
const errorCount = Object.values(r.stats.errorsByCode).reduce((a, b) => a + b, 0);
|
|
220
|
+
const avg =
|
|
221
|
+
r.stats.duration.count > 0
|
|
222
|
+
? r.stats.duration.sum / r.stats.duration.count
|
|
223
|
+
: 0;
|
|
224
|
+
return (
|
|
225
|
+
<tr key={r.method}>
|
|
226
|
+
<td className="method-name" title={r.method}>{r.method}</td>
|
|
227
|
+
<td className="num">{r.stats.calls}</td>
|
|
228
|
+
<td className={`num ${rateClass(r.callsPerSecond)}`}>{formatRate(r.callsPerSecond)}</td>
|
|
229
|
+
<td className={`num${errorCount > 0 ? ' error' : ''}`}>{errorCount}</td>
|
|
230
|
+
<td className={`num${r.errorRate > 0.05 ? ' error' : ''}`}>{formatPercent(r.errorRate)}</td>
|
|
231
|
+
<td className="num">{formatDuration(r.p50)}</td>
|
|
232
|
+
<td className="num">{formatDuration(r.p95)}</td>
|
|
233
|
+
<td className="num">{formatDuration(r.p99)}</td>
|
|
234
|
+
<td className="num">{formatDuration(avg)}</td>
|
|
235
|
+
</tr>
|
|
236
|
+
);
|
|
237
|
+
})}
|
|
238
|
+
</tbody>
|
|
239
|
+
</table>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function StreamTable({ rows }: { rows: MethodDerived[] }) {
|
|
244
|
+
return (
|
|
245
|
+
<table className="metrics-table">
|
|
246
|
+
<thead>
|
|
247
|
+
<tr>
|
|
248
|
+
<th>method</th>
|
|
249
|
+
<th className="num">opened</th>
|
|
250
|
+
<th className="num">completed</th>
|
|
251
|
+
<th className="num">failed</th>
|
|
252
|
+
<th className="num">aborted</th>
|
|
253
|
+
<th className="num">complete%</th>
|
|
254
|
+
<th className="num">abort%</th>
|
|
255
|
+
<th className="num">avg chunks</th>
|
|
256
|
+
<th className="num">ttfb p50</th>
|
|
257
|
+
<th className="num">ttfb p95</th>
|
|
258
|
+
<th className="num">duration avg</th>
|
|
259
|
+
</tr>
|
|
260
|
+
</thead>
|
|
261
|
+
<tbody>
|
|
262
|
+
{rows.map((r) => (
|
|
263
|
+
<tr key={r.method}>
|
|
264
|
+
<td className="method-name" title={r.method}>{r.method}</td>
|
|
265
|
+
<td className="num">{r.stats.streams.opened}</td>
|
|
266
|
+
<td className="num">{r.stats.streams.completed}</td>
|
|
267
|
+
<td className={`num${r.stats.streams.failed > 0 ? ' error' : ''}`}>
|
|
268
|
+
{r.stats.streams.failed}
|
|
269
|
+
</td>
|
|
270
|
+
<td className={`num${r.stats.streams.aborted > 0 ? ' warn' : ''}`}>
|
|
271
|
+
{r.stats.streams.aborted}
|
|
272
|
+
</td>
|
|
273
|
+
<td className="num">{formatPercent(r.streamCompletionRate)}</td>
|
|
274
|
+
<td className="num">{formatPercent(r.streamAbortRate)}</td>
|
|
275
|
+
<td className="num">{r.avgChunksPerStream.toFixed(1)}</td>
|
|
276
|
+
<td className="num">{formatDuration(r.ttfbP50)}</td>
|
|
277
|
+
<td className="num">{formatDuration(r.ttfbP95)}</td>
|
|
278
|
+
<td className="num">
|
|
279
|
+
{formatDuration(
|
|
280
|
+
r.stats.streamDuration.count > 0
|
|
281
|
+
? r.stats.streamDuration.sum / r.stats.streamDuration.count
|
|
282
|
+
: 0,
|
|
283
|
+
)}
|
|
284
|
+
</td>
|
|
285
|
+
</tr>
|
|
286
|
+
))}
|
|
287
|
+
</tbody>
|
|
288
|
+
</table>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
@@ -17,8 +17,8 @@ const KIND_COLORS: Record<string, string> = {
|
|
|
17
17
|
record: 'var(--syn-object)',
|
|
18
18
|
union: 'var(--syn-union)',
|
|
19
19
|
enum: 'var(--syn-union)',
|
|
20
|
-
nullable: 'var(--syn-muted)',
|
|
21
20
|
unknown: 'var(--syn-muted)',
|
|
21
|
+
null: 'var(--syn-muted)',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
function colorOf(kind: string): string {
|
|
@@ -93,33 +93,20 @@ function SchemaField({
|
|
|
93
93
|
const [open, setOpen] = useState(defaultOpen);
|
|
94
94
|
const kind = schema.kind;
|
|
95
95
|
|
|
96
|
-
// ── nullable
|
|
97
|
-
if (
|
|
98
|
-
const inner =
|
|
99
|
-
schema.children && isSchema(schema.children.inner)
|
|
100
|
-
? schema.children.inner
|
|
101
|
-
: undefined;
|
|
102
|
-
if (!inner) {
|
|
103
|
-
return (
|
|
104
|
-
<UnknownLeaf
|
|
105
|
-
schema={schema}
|
|
106
|
-
value={value}
|
|
107
|
-
onChange={onChange}
|
|
108
|
-
label={label}
|
|
109
|
-
depth={depth}
|
|
110
|
-
disabled={disabled}
|
|
111
|
-
/>
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
96
|
+
// ── nullable 标记:标题 + 控制按钮同行,下面递归真实 kind ──
|
|
97
|
+
if (schema.nullable) {
|
|
115
98
|
const isNull = value === null || value === undefined;
|
|
116
99
|
return (
|
|
117
100
|
<div className="sf-node sf-row" style={{ paddingLeft: depth * 14 }}>
|
|
118
101
|
<div className="sf-head sf-head-inline">
|
|
119
102
|
{label !== undefined && <span className="sf-label">{label}</span>}
|
|
120
|
-
<span className="sf-kind" style={{ color: colorOf(
|
|
121
|
-
|
|
103
|
+
<span className="sf-kind" style={{ color: colorOf(kind) }}>
|
|
104
|
+
{kind}
|
|
122
105
|
</span>
|
|
106
|
+
<span className="sf-nullable-badge">nullable</span>
|
|
107
|
+
{schema.description && (
|
|
108
|
+
<span className="sf-desc">— {schema.description}</span>
|
|
109
|
+
)}
|
|
123
110
|
{!isNull && (
|
|
124
111
|
<button
|
|
125
112
|
type="button"
|
|
@@ -135,7 +122,14 @@ function SchemaField({
|
|
|
135
122
|
type="button"
|
|
136
123
|
className="tiny"
|
|
137
124
|
disabled={disabled}
|
|
138
|
-
onClick={() =>
|
|
125
|
+
onClick={() =>
|
|
126
|
+
onChange(
|
|
127
|
+
sampleFromSchema({
|
|
128
|
+
...schema,
|
|
129
|
+
nullable: false,
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
}
|
|
139
133
|
>
|
|
140
134
|
填值
|
|
141
135
|
</button>
|
|
@@ -146,7 +140,7 @@ function SchemaField({
|
|
|
146
140
|
<span className="sf-null-indicator">null</span>
|
|
147
141
|
) : (
|
|
148
142
|
<SchemaField
|
|
149
|
-
schema={
|
|
143
|
+
schema={{ ...schema, nullable: false }}
|
|
150
144
|
value={value}
|
|
151
145
|
onChange={onChange}
|
|
152
146
|
depth={0}
|
|
@@ -252,7 +246,7 @@ function SchemaField({
|
|
|
252
246
|
);
|
|
253
247
|
}
|
|
254
248
|
|
|
255
|
-
// ── unknown / 其他兜底 ──
|
|
249
|
+
// ── unknown / null / 其他兜底 ──
|
|
256
250
|
return (
|
|
257
251
|
<UnknownLeaf
|
|
258
252
|
schema={schema}
|
|
@@ -275,7 +269,8 @@ function isScalarKind(kind: string): boolean {
|
|
|
275
269
|
kind === 'number' ||
|
|
276
270
|
kind === 'boolean' ||
|
|
277
271
|
kind === 'enum' ||
|
|
278
|
-
kind === 'literal'
|
|
272
|
+
kind === 'literal' ||
|
|
273
|
+
kind === 'null'
|
|
279
274
|
);
|
|
280
275
|
}
|
|
281
276
|
|
|
@@ -343,6 +338,13 @@ function ScalarInput({
|
|
|
343
338
|
<span className="hint">(固定值)</span>
|
|
344
339
|
</div>
|
|
345
340
|
);
|
|
341
|
+
case 'null':
|
|
342
|
+
return (
|
|
343
|
+
<div className="sf-scalar">
|
|
344
|
+
<code className="literal-value">null</code>
|
|
345
|
+
<span className="hint">(固定值)</span>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
346
348
|
default:
|
|
347
349
|
// string
|
|
348
350
|
return (
|
|
@@ -627,7 +629,7 @@ function AddRecordKey({
|
|
|
627
629
|
}
|
|
628
630
|
|
|
629
631
|
// ─────────────────────────────────────────────────────────────
|
|
630
|
-
// unknown:JSON textarea(标题 + textarea 同一行)
|
|
632
|
+
// unknown / null:JSON textarea(标题 + textarea 同一行)
|
|
631
633
|
// ─────────────────────────────────────────────────────────────
|
|
632
634
|
|
|
633
635
|
function UnknownLeaf({
|
|
@@ -20,8 +20,8 @@ const KIND_COLORS: Record<string, string> = {
|
|
|
20
20
|
record: 'var(--syn-object)',
|
|
21
21
|
union: 'var(--syn-union)',
|
|
22
22
|
enum: 'var(--syn-union)',
|
|
23
|
-
nullable: 'var(--syn-muted)',
|
|
24
23
|
unknown: 'var(--syn-muted)',
|
|
24
|
+
null: 'var(--syn-muted)',
|
|
25
25
|
stream: 'var(--syn-container)',
|
|
26
26
|
};
|
|
27
27
|
|
|
@@ -64,7 +64,7 @@ function SchemaNode({ schema, depth, defaultOpen, label }: NodeProps) {
|
|
|
64
64
|
{schema.kind}
|
|
65
65
|
</span>
|
|
66
66
|
{schema.kind === 'stream' && <span className="schema-nullable"><stream></span>}
|
|
67
|
-
{schema.nullable && <span className="schema-nullable"
|
|
67
|
+
{schema.nullable && <span className="schema-nullable-badge">nullable</span>}
|
|
68
68
|
{constraintsText && <span className="schema-constraints">{`{ ${constraintsText} }`}</span>}
|
|
69
69
|
{schema.description && (
|
|
70
70
|
<span className="schema-description" title={schema.description}>
|
|
@@ -84,7 +84,7 @@ function renderChildren(schema: SchemaDescription, depth: number): React.ReactNo
|
|
|
84
84
|
const c = schema.children ?? {};
|
|
85
85
|
const out: React.ReactNode[] = [];
|
|
86
86
|
|
|
87
|
-
//
|
|
87
|
+
// stream 包装器
|
|
88
88
|
if (c.inner && isSchemaDescription(c.inner)) {
|
|
89
89
|
out.push(<SchemaNode key="inner" schema={c.inner} depth={depth} defaultOpen />);
|
|
90
90
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { RpcClient } from '../api/rpc';
|
|
3
|
+
import {
|
|
4
|
+
fetchMetrics,
|
|
5
|
+
type MetricsSnapshot,
|
|
6
|
+
} from '../api/metrics';
|
|
7
|
+
|
|
8
|
+
export interface UseMetricsState {
|
|
9
|
+
snapshot: MetricsSnapshot | undefined;
|
|
10
|
+
previous: { snapshot: MetricsSnapshot; fetchedAt: number } | undefined;
|
|
11
|
+
fetchedAt: number | undefined;
|
|
12
|
+
loading: boolean;
|
|
13
|
+
error: string | null;
|
|
14
|
+
/** 手动刷新。 */
|
|
15
|
+
refresh: () => Promise<void>;
|
|
16
|
+
/** 是否自动刷新。 */
|
|
17
|
+
autoRefresh: boolean;
|
|
18
|
+
setAutoRefresh: (v: boolean) => void;
|
|
19
|
+
/** 轮询间隔(毫秒)。 */
|
|
20
|
+
intervalMs: number;
|
|
21
|
+
setIntervalMs: (v: number) => void;
|
|
22
|
+
/** system.metrics 是否可用。 */
|
|
23
|
+
available: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useMetrics(rpc: RpcClient, enabled: boolean): UseMetricsState {
|
|
27
|
+
const [snapshot, setSnapshot] = useState<MetricsSnapshot | undefined>(undefined);
|
|
28
|
+
const [previous, setPrevious] = useState<{ snapshot: MetricsSnapshot; fetchedAt: number } | undefined>(undefined);
|
|
29
|
+
const [fetchedAt, setFetchedAt] = useState<number | undefined>(undefined);
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [error, setError] = useState<string | null>(null);
|
|
32
|
+
const [available, setAvailable] = useState(true);
|
|
33
|
+
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
34
|
+
const [intervalMs, setIntervalMs] = useState(3000);
|
|
35
|
+
const inFlight = useRef(false);
|
|
36
|
+
|
|
37
|
+
const refresh = useCallback(async () => {
|
|
38
|
+
if (inFlight.current) return;
|
|
39
|
+
inFlight.current = true;
|
|
40
|
+
setLoading(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
try {
|
|
43
|
+
const next = await fetchMetrics(rpc);
|
|
44
|
+
if (!next) {
|
|
45
|
+
setAvailable(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setAvailable(true);
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
setSnapshot((curr) => {
|
|
51
|
+
if (curr) setPrevious({ snapshot: curr, fetchedAt: fetchedAt ?? now });
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
setFetchedAt(now);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
inFlight.current = false;
|
|
60
|
+
}
|
|
61
|
+
}, [rpc, fetchedAt]);
|
|
62
|
+
|
|
63
|
+
// 首次进入 / 切到 metrics 面板时拉一次
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!enabled) return;
|
|
66
|
+
void refresh();
|
|
67
|
+
// 只在 enabled 变化或 rpc 实例变化时重置
|
|
68
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
69
|
+
}, [enabled, rpc]);
|
|
70
|
+
|
|
71
|
+
// 自动刷新
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!enabled || !autoRefresh || !available) return;
|
|
74
|
+
const t = setInterval(() => void refresh(), intervalMs);
|
|
75
|
+
return () => clearInterval(t);
|
|
76
|
+
}, [enabled, autoRefresh, available, intervalMs, refresh]);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
snapshot,
|
|
80
|
+
previous,
|
|
81
|
+
fetchedAt,
|
|
82
|
+
loading,
|
|
83
|
+
error,
|
|
84
|
+
refresh,
|
|
85
|
+
autoRefresh,
|
|
86
|
+
setAutoRefresh,
|
|
87
|
+
intervalMs,
|
|
88
|
+
setIntervalMs,
|
|
89
|
+
available,
|
|
90
|
+
};
|
|
91
|
+
}
|