@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.
@@ -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 包装:标题 + 控制按钮同行,下面递归或显示 null ──
97
- if (kind === 'nullable') {
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('nullable') }}>
121
- nullable
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={() => onChange(sampleFromSchema(inner))}
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={inner}
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">&lt;stream&gt;</span>}
67
- {schema.nullable && <span className="schema-nullable">| null</span>}
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
- // nullable / stream 包装器
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
+ }