@winstonsayno/mcp-gateway 1.0.0 → 1.1.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/CHANGELOG.md +22 -0
- package/README.md +14 -4
- package/dashboard/README.md +60 -15
- package/dashboard/index.html +1529 -419
- package/dist/gateway/index.d.ts +1 -0
- package/dist/gateway/index.d.ts.map +1 -1
- package/dist/gateway/index.js +6 -0
- package/dist/gateway/index.js.map +1 -1
- package/dist/gateway/live.d.ts +85 -0
- package/dist/gateway/live.d.ts.map +1 -0
- package/dist/gateway/live.js +206 -0
- package/dist/gateway/live.js.map +1 -0
- package/dist/proxy/index.d.ts +1 -0
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +4 -2
- package/dist/proxy/index.js.map +1 -1
- package/package.json +1 -1
package/dashboard/index.html
CHANGED
|
@@ -1,472 +1,1582 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
3
|
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
6
|
+
<meta name="color-scheme" content="dark light" />
|
|
7
|
+
<meta name="theme-color" content="#0b0d12" />
|
|
8
|
+
<title>mcp-gateway · Dashboard</title>
|
|
9
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236d5efc'/%3E%3Cpath d='M9 22V10l7 7 7-7v12' stroke='white' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
|
10
|
+
<!--
|
|
11
|
+
mcp-gateway dashboard v2 — one self-contained file: no build step, no CDN,
|
|
12
|
+
no runtime dependencies. Charts are hand-drawn SVG. Data comes only from the
|
|
13
|
+
gateway's own API (/api/v1/*), live updates from the /api/v1/events SSE
|
|
14
|
+
stream (read with fetch() so the API key travels in a header), falling back
|
|
15
|
+
to polling every 2 s.
|
|
16
|
+
-->
|
|
17
|
+
<style>
|
|
18
|
+
:root {
|
|
19
|
+
--radius: 14px;
|
|
20
|
+
--radius-sm: 9px;
|
|
21
|
+
--ease: cubic-bezier(.2, .8, .2, 1);
|
|
22
|
+
--spring: cubic-bezier(.34, 1.56, .64, 1);
|
|
23
|
+
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", sans-serif;
|
|
24
|
+
--mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
|
|
25
|
+
--accent: #7c6cff;
|
|
26
|
+
--accent-2: #22d3ee;
|
|
27
|
+
--ok: #22c55e;
|
|
28
|
+
--warn: #f59e0b;
|
|
29
|
+
--bad: #ef4444;
|
|
30
|
+
--info: #3b82f6;
|
|
31
|
+
}
|
|
32
|
+
[data-theme="dark"] {
|
|
33
|
+
--bg: #0b0d12;
|
|
34
|
+
--bg-2: #10131a;
|
|
35
|
+
--surface: rgba(22, 26, 36, .78);
|
|
36
|
+
--surface-solid: #161a24;
|
|
37
|
+
--surface-2: #1c2130;
|
|
38
|
+
--border: rgba(255, 255, 255, .08);
|
|
39
|
+
--border-strong: rgba(255, 255, 255, .16);
|
|
40
|
+
--text: #e8ebf3;
|
|
41
|
+
--muted: #8d95a8;
|
|
42
|
+
--faint: #5b6377;
|
|
43
|
+
--shadow: 0 10px 30px -12px rgba(0, 0, 0, .6);
|
|
44
|
+
--grid: rgba(255, 255, 255, .06);
|
|
45
|
+
--glow: radial-gradient(1200px 600px at 10% -10%, rgba(124, 108, 255, .18), transparent 60%),
|
|
46
|
+
radial-gradient(900px 500px at 110% 0%, rgba(34, 211, 238, .10), transparent 60%);
|
|
47
|
+
}
|
|
48
|
+
[data-theme="light"] {
|
|
49
|
+
--bg: #f5f6fa;
|
|
50
|
+
--bg-2: #eef0f6;
|
|
51
|
+
--surface: rgba(255, 255, 255, .85);
|
|
52
|
+
--surface-solid: #ffffff;
|
|
53
|
+
--surface-2: #f1f3f8;
|
|
54
|
+
--border: rgba(15, 23, 42, .09);
|
|
55
|
+
--border-strong: rgba(15, 23, 42, .18);
|
|
56
|
+
--text: #141824;
|
|
57
|
+
--muted: #5d667a;
|
|
58
|
+
--faint: #98a0b3;
|
|
59
|
+
--shadow: 0 10px 30px -14px rgba(30, 41, 80, .25);
|
|
60
|
+
--grid: rgba(15, 23, 42, .07);
|
|
61
|
+
--accent: #5b4bf0;
|
|
62
|
+
--glow: radial-gradient(1200px 600px at 10% -10%, rgba(91, 75, 240, .10), transparent 60%),
|
|
63
|
+
radial-gradient(900px 500px at 110% 0%, rgba(6, 182, 212, .08), transparent 60%);
|
|
64
|
+
}
|
|
65
|
+
* { box-sizing: border-box; }
|
|
66
|
+
[hidden] { display: none !important; }
|
|
67
|
+
html, body { margin: 0; }
|
|
68
|
+
body {
|
|
69
|
+
font: 14px/1.5 var(--font);
|
|
70
|
+
color: var(--text);
|
|
71
|
+
background: var(--bg);
|
|
72
|
+
background-image: var(--glow);
|
|
73
|
+
background-attachment: fixed;
|
|
74
|
+
min-height: 100vh;
|
|
75
|
+
-webkit-font-smoothing: antialiased;
|
|
76
|
+
-webkit-tap-highlight-color: transparent;
|
|
77
|
+
}
|
|
78
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
79
|
+
a { color: var(--accent); }
|
|
80
|
+
code, pre, .mono { font-family: var(--mono); font-size: 12.5px; }
|
|
81
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
|
|
82
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
83
|
+
.skip { position: absolute; left: 12px; top: -40px; z-index: 100; background: var(--accent); color: #fff; padding: 8px 12px; border-radius: 8px; transition: transform .2s var(--ease); }
|
|
84
|
+
.skip:focus { transform: translateY(52px); }
|
|
40
85
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
86
|
+
/* ── Top bar ─────────────────────────────────────────── */
|
|
87
|
+
.topbar {
|
|
88
|
+
position: sticky; top: 0; z-index: 20;
|
|
89
|
+
display: flex; align-items: center; gap: 12px;
|
|
90
|
+
padding: 10px max(16px, env(safe-area-inset-right)) 10px max(16px, env(safe-area-inset-left));
|
|
91
|
+
background: color-mix(in srgb, var(--bg) 78%, transparent);
|
|
92
|
+
backdrop-filter: saturate(1.4) blur(14px);
|
|
93
|
+
-webkit-backdrop-filter: saturate(1.4) blur(14px);
|
|
94
|
+
border-bottom: 1px solid var(--border);
|
|
95
|
+
}
|
|
96
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
|
|
97
|
+
.logo { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; background: linear-gradient(135deg, var(--accent), var(--accent-2)); box-shadow: 0 6px 18px -6px var(--accent); }
|
|
98
|
+
.brand small { color: var(--muted); font-weight: 500; }
|
|
99
|
+
.tabs { display: flex; gap: 2px; margin-left: 12px; position: relative; padding: 3px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 11px; }
|
|
100
|
+
.tab { position: relative; z-index: 1; border: 0; background: none; padding: 6px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); display: flex; align-items: center; gap: 6px; transition: color .2s; white-space: nowrap; }
|
|
101
|
+
.tab[aria-selected="true"] { color: var(--text); }
|
|
102
|
+
.tab:hover { color: var(--text); }
|
|
103
|
+
.tab-ink { position: absolute; z-index: 0; top: 3px; left: 0; height: calc(100% - 6px); width: 10px; border-radius: 8px; background: var(--surface-solid); box-shadow: 0 1px 2px rgba(0,0,0,.2), inset 0 0 0 1px var(--border); transform-origin: 0 0; transition: transform .35s var(--ease); }
|
|
104
|
+
.spacer { flex: 1; }
|
|
105
|
+
.pill { display: inline-flex; align-items: center; gap: 7px; padding: 4px 10px; border-radius: 999px; font-size: 12px; border: 1px solid var(--border); background: var(--surface-2); color: var(--muted); white-space: nowrap; }
|
|
106
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
|
107
|
+
.dot.ok { background: var(--ok); box-shadow: 0 0 0 0 rgba(34,197,94,.5); animation: pulse 2s infinite; }
|
|
108
|
+
.dot.warn { background: var(--warn); }
|
|
109
|
+
.dot.bad { background: var(--bad); }
|
|
110
|
+
.dot.info { background: var(--info); }
|
|
111
|
+
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(34,197,94,.45); } 70% { box-shadow: 0 0 0 7px rgba(34,197,94,0); } 100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); } }
|
|
112
|
+
.icon-btn { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; border: 1px solid var(--border); background: var(--surface-2); cursor: pointer; transition: transform .15s var(--ease), background .2s; flex: none; }
|
|
113
|
+
.icon-btn:hover { background: var(--surface-solid); }
|
|
114
|
+
.icon-btn:active { transform: scale(.92); }
|
|
115
|
+
.icon-btn svg { width: 17px; height: 17px; }
|
|
116
|
+
.lang-btn { font-size: 12px; font-weight: 600; width: auto; padding: 0 10px; }
|
|
117
|
+
|
|
118
|
+
/* ── Layout ──────────────────────────────────────────── */
|
|
119
|
+
main { max-width: 1320px; margin: 0 auto; padding: 20px 16px 96px; }
|
|
120
|
+
.view { display: none; }
|
|
121
|
+
.view.active { display: block; animation: viewIn .38s var(--ease) both; }
|
|
122
|
+
@keyframes viewIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
123
|
+
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin: 4px 0 16px; flex-wrap: wrap; }
|
|
124
|
+
.page-head h1 { font-size: 22px; letter-spacing: -.02em; margin: 0; }
|
|
125
|
+
.page-head p { margin: 2px 0 0; color: var(--muted); }
|
|
126
|
+
.grid { display: grid; gap: 14px; }
|
|
127
|
+
.kpis { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
|
128
|
+
.two { grid-template-columns: 1.6fr 1fr; }
|
|
129
|
+
.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
130
|
+
.card {
|
|
131
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
|
132
|
+
box-shadow: var(--shadow); padding: 16px; min-width: 0; position: relative;
|
|
133
|
+
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
134
|
+
}
|
|
135
|
+
.card h2 { font-size: 13px; font-weight: 600; margin: 0 0 12px; color: var(--muted); display: flex; align-items: center; gap: 8px; text-transform: none; letter-spacing: .01em; }
|
|
136
|
+
.card h2 .right { margin-left: auto; font-weight: 500; color: var(--faint); font-size: 12px; }
|
|
137
|
+
.kpi .label { color: var(--muted); font-size: 12.5px; display: flex; align-items: center; gap: 6px; }
|
|
138
|
+
.kpi .value { font-size: 28px; font-weight: 680; letter-spacing: -.03em; margin-top: 4px; font-variant-numeric: tabular-nums; line-height: 1.15; }
|
|
139
|
+
.kpi .value small { font-size: 14px; color: var(--muted); font-weight: 500; margin-left: 2px; }
|
|
140
|
+
.kpi .sub { color: var(--faint); font-size: 12px; margin-top: 2px; }
|
|
141
|
+
.kpi .spark { position: absolute; right: 12px; bottom: 12px; width: 84px; height: 30px; opacity: .9; }
|
|
142
|
+
.kpi:hover { border-color: var(--border-strong); }
|
|
143
|
+
.stagger > * { animation: rise .5s var(--ease) both; }
|
|
144
|
+
.stagger > *:nth-child(2) { animation-delay: .04s; } .stagger > *:nth-child(3) { animation-delay: .08s; }
|
|
145
|
+
.stagger > *:nth-child(4) { animation-delay: .12s; } .stagger > *:nth-child(5) { animation-delay: .16s; }
|
|
146
|
+
.stagger > *:nth-child(6) { animation-delay: .2s; }
|
|
147
|
+
@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.99); } to { opacity: 1; transform: none; } }
|
|
148
|
+
|
|
149
|
+
/* ── Charts ──────────────────────────────────────────── */
|
|
150
|
+
.chart { width: 100%; height: 190px; display: block; overflow: visible; }
|
|
151
|
+
.chart text { fill: var(--faint); font-size: 10.5px; font-family: var(--font); }
|
|
152
|
+
.chart .gl { stroke: var(--grid); stroke-width: 1; }
|
|
153
|
+
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--muted); margin-top: 6px; flex-wrap: wrap; }
|
|
154
|
+
.legend i { display: inline-block; width: 10px; height: 3px; border-radius: 2px; vertical-align: middle; margin-right: 6px; }
|
|
155
|
+
.chart-tip { position: absolute; pointer-events: none; padding: 6px 9px; border-radius: 8px; background: var(--surface-solid); border: 1px solid var(--border-strong); box-shadow: var(--shadow); font-size: 12px; opacity: 0; transform: translateY(4px); transition: opacity .15s, transform .15s var(--ease); white-space: nowrap; z-index: 3; }
|
|
156
|
+
.chart-tip.show { opacity: 1; transform: none; }
|
|
157
|
+
.chart-wrap { position: relative; }
|
|
158
|
+
.seg { display: inline-flex; padding: 3px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; gap: 2px; }
|
|
159
|
+
.seg button { border: 0; background: none; padding: 4px 10px; border-radius: 7px; cursor: pointer; color: var(--muted); font-size: 12px; transition: background .2s, color .2s; }
|
|
160
|
+
.seg button[aria-pressed="true"] { background: var(--surface-solid); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
|
|
161
|
+
|
|
162
|
+
/* ── Lists ───────────────────────────────────────────── */
|
|
163
|
+
.bars { display: grid; gap: 10px; }
|
|
164
|
+
.bar-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; font-size: 12.5px; }
|
|
165
|
+
.bar-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
166
|
+
.bar-row .name span { color: var(--faint); }
|
|
167
|
+
.bar-row .num { color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
168
|
+
.bar-track { grid-column: 1 / -1; height: 6px; border-radius: 4px; background: var(--surface-2); overflow: hidden; }
|
|
169
|
+
.bar-fill { height: 100%; width: 100%; border-radius: 4px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transform-origin: 0 50%; transform: scaleX(0); transition: transform .7s var(--ease); }
|
|
170
|
+
.bar-fill.err { background: linear-gradient(90deg, var(--warn), var(--bad)); }
|
|
171
|
+
.stream { list-style: none; margin: 0; padding: 0; max-height: 360px; overflow: hidden auto; display: grid; gap: 6px; contain: content; }
|
|
172
|
+
.stream li { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--surface-2); border: 1px solid transparent; font-size: 12.5px; }
|
|
173
|
+
.stream li.enter { animation: slideIn .45s var(--ease) both; }
|
|
174
|
+
.stream li .what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
175
|
+
.stream li .what span { color: var(--faint); }
|
|
176
|
+
.stream li .meta { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
177
|
+
@keyframes slideIn { from { opacity: 0; transform: translateY(-10px) scale(.98); } to { opacity: 1; transform: none; } }
|
|
178
|
+
.empty { color: var(--faint); text-align: center; padding: 24px 8px; font-size: 13px; }
|
|
179
|
+
.empty svg { display: block; margin: 0 auto 8px; opacity: .6; }
|
|
180
|
+
|
|
181
|
+
/* ── Servers ─────────────────────────────────────────── */
|
|
182
|
+
.servers { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
|
183
|
+
.srv { display: grid; gap: 10px; transition: transform .25s var(--ease), border-color .25s; }
|
|
184
|
+
.srv:hover { transform: translateY(-2px); border-color: var(--border-strong); }
|
|
185
|
+
.srv-head { display: flex; align-items: center; gap: 10px; }
|
|
186
|
+
.srv-head b { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
187
|
+
.srv-head .badge { margin-left: auto; }
|
|
188
|
+
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 600; background: var(--surface-2); color: var(--muted); border: 1px solid var(--border); white-space: nowrap; }
|
|
189
|
+
.badge.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
|
|
190
|
+
.badge.warn { color: var(--warn); background: color-mix(in srgb, var(--warn) 12%, transparent); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
|
|
191
|
+
.badge.bad { color: var(--bad); background: color-mix(in srgb, var(--bad) 12%, transparent); border-color: color-mix(in srgb, var(--bad) 25%, transparent); }
|
|
192
|
+
.badge.info { color: var(--info); background: color-mix(in srgb, var(--info) 12%, transparent); border-color: color-mix(in srgb, var(--info) 25%, transparent); }
|
|
193
|
+
.kv { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
|
194
|
+
.kv div { background: var(--surface-2); border-radius: 9px; padding: 7px 9px; min-width: 0; }
|
|
195
|
+
.kv span { display: block; color: var(--faint); font-size: 11px; }
|
|
196
|
+
.kv b { font-size: 13.5px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
|
197
|
+
.srv .err { color: var(--bad); font-size: 12px; overflow-wrap: anywhere; }
|
|
198
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
199
|
+
.chip { font-family: var(--mono); font-size: 11.5px; padding: 3px 8px; border-radius: 7px; background: var(--surface-2); border: 1px solid var(--border); cursor: pointer; transition: border-color .2s, transform .15s var(--ease); }
|
|
200
|
+
.chip:hover { border-color: var(--accent); }
|
|
201
|
+
.chip:active { transform: scale(.95); }
|
|
202
|
+
|
|
203
|
+
/* ── Buttons & forms ─────────────────────────────────── */
|
|
204
|
+
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border-strong); background: var(--surface-2); cursor: pointer; font-weight: 550; transition: transform .15s var(--ease), background .2s, border-color .2s, opacity .2s; white-space: nowrap; min-height: 36px; }
|
|
205
|
+
.btn:hover { background: var(--surface-solid); }
|
|
206
|
+
.btn:active { transform: scale(.96); }
|
|
207
|
+
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none; }
|
|
208
|
+
.btn.primary { background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, var(--accent-2))); border-color: transparent; color: #fff; box-shadow: 0 8px 20px -10px var(--accent); }
|
|
209
|
+
.btn.primary:hover { filter: brightness(1.08); }
|
|
210
|
+
.btn.ghost { background: none; border-color: transparent; color: var(--muted); }
|
|
211
|
+
.btn.ghost:hover { color: var(--text); background: var(--surface-2); }
|
|
212
|
+
.btn.sm { padding: 4px 10px; min-height: 28px; font-size: 12.5px; border-radius: 8px; }
|
|
213
|
+
.btn svg { width: 15px; height: 15px; }
|
|
214
|
+
.btn .spin { display: none; }
|
|
215
|
+
.btn.loading .spin { display: block; animation: spin .8s linear infinite; }
|
|
216
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
217
|
+
.field { display: grid; gap: 5px; }
|
|
218
|
+
.field label { font-size: 12.5px; color: var(--muted); display: flex; gap: 6px; align-items: baseline; }
|
|
219
|
+
.field label .req { color: var(--bad); }
|
|
220
|
+
.field label .type { color: var(--faint); font-family: var(--mono); font-size: 11px; }
|
|
221
|
+
.field .hint { color: var(--faint); font-size: 12px; }
|
|
222
|
+
.input, select.input, textarea.input { width: 100%; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--border-strong); background: var(--bg-2); transition: border-color .2s, box-shadow .2s; min-height: 38px; }
|
|
223
|
+
.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); }
|
|
224
|
+
.input.invalid { border-color: var(--bad); }
|
|
225
|
+
input.input, select.input { height: 38px; }
|
|
226
|
+
textarea.input { font-family: var(--mono); font-size: 12.5px; resize: vertical; min-height: 80px; }
|
|
227
|
+
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
228
|
+
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; color: var(--muted); font-size: 13px; }
|
|
229
|
+
.switch input { appearance: none; width: 36px; height: 21px; border-radius: 999px; background: var(--surface-2); border: 1px solid var(--border-strong); position: relative; cursor: pointer; transition: background .2s; margin: 0; flex: none; }
|
|
230
|
+
.switch input::after { content: ""; position: absolute; top: 2px; left: 2px; width: 15px; height: 15px; border-radius: 50%; background: var(--muted); transition: transform .25s var(--spring), background .2s; }
|
|
231
|
+
.switch input:checked { background: var(--accent); border-color: var(--accent); }
|
|
232
|
+
.switch input:checked::after { transform: translateX(15px); background: #fff; }
|
|
233
|
+
.filters { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)) auto; gap: 10px; align-items: end; }
|
|
234
|
+
|
|
235
|
+
/* ── Playground ──────────────────────────────────────── */
|
|
236
|
+
.pg { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px; align-items: start; }
|
|
237
|
+
.tool-list { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 4px; max-height: 520px; overflow: auto; }
|
|
238
|
+
.tool-list button { width: 100%; text-align: left; border: 1px solid transparent; background: none; padding: 8px 10px; border-radius: 9px; cursor: pointer; display: grid; gap: 1px; transition: background .2s, border-color .2s; }
|
|
239
|
+
.tool-list button:hover { background: var(--surface-2); }
|
|
240
|
+
.tool-list button[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 14%, transparent); border-color: color-mix(in srgb, var(--accent) 40%, transparent); }
|
|
241
|
+
.tool-list b { font-family: var(--mono); font-size: 12.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
242
|
+
.tool-list span { color: var(--faint); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
243
|
+
.form { display: grid; gap: 12px; }
|
|
244
|
+
.result { margin-top: 14px; }
|
|
245
|
+
.result pre { margin: 0; padding: 12px; border-radius: 10px; background: var(--bg-2); border: 1px solid var(--border); overflow: auto; max-height: 360px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
246
|
+
.result.ok pre { border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
|
|
247
|
+
.result.bad pre { border-color: color-mix(in srgb, var(--bad) 35%, transparent); }
|
|
248
|
+
.result .row { margin-bottom: 8px; }
|
|
249
|
+
.pop { animation: pop .4s var(--spring) both; }
|
|
250
|
+
@keyframes pop { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: none; } }
|
|
251
|
+
|
|
252
|
+
/* ── Tables ──────────────────────────────────────────── */
|
|
253
|
+
.table-wrap { overflow: auto; border-radius: 10px; }
|
|
254
|
+
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
|
255
|
+
th { text-align: left; color: var(--faint); font-weight: 600; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
256
|
+
td { padding: 9px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; max-width: 280px; overflow: hidden; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
|
|
257
|
+
tbody tr { transition: background .15s; }
|
|
258
|
+
tbody tr:hover { background: var(--surface-2); }
|
|
259
|
+
.pager { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; gap: 10px; flex-wrap: wrap; color: var(--muted); font-size: 12.5px; }
|
|
260
|
+
|
|
261
|
+
/* ── Code snippets ───────────────────────────────────── */
|
|
262
|
+
.snip-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
263
|
+
.snip-tabs button { border: 1px solid var(--border); background: var(--surface-2); padding: 6px 12px; border-radius: 9px; cursor: pointer; font-size: 12.5px; color: var(--muted); transition: all .2s; }
|
|
264
|
+
.snip-tabs button[aria-selected="true"] { color: var(--text); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
|
265
|
+
.code { position: relative; }
|
|
266
|
+
.code pre { margin: 0; padding: 14px; padding-right: 48px; border-radius: 12px; background: #0a0c11; color: #d7dbe6; border: 1px solid var(--border); overflow: auto; max-height: 340px; font-size: 12.5px; line-height: 1.6; }
|
|
267
|
+
[data-theme="light"] .code pre { background: #12151d; }
|
|
268
|
+
.code .copy { position: absolute; top: 8px; right: 8px; }
|
|
269
|
+
.code .tk-s { color: #a5e3a1; } .code .tk-k { color: #c4a7ff; } .code .tk-n { color: #f5c07a; } .code .tk-c { color: #6b7488; }
|
|
270
|
+
.snip-note { color: var(--muted); font-size: 12.5px; margin: 8px 0 0; }
|
|
271
|
+
|
|
272
|
+
/* ── Skeletons ───────────────────────────────────────── */
|
|
273
|
+
.sk { position: relative; overflow: hidden; background: var(--surface-2); border-radius: 8px; color: transparent !important; }
|
|
274
|
+
.sk::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--text) 7%, transparent), transparent); animation: shimmer 1.3s infinite; }
|
|
275
|
+
@keyframes shimmer { to { transform: translateX(100%); } }
|
|
276
|
+
.sk-line { height: 12px; margin: 8px 0; }
|
|
277
|
+
|
|
278
|
+
/* ── Onboarding ──────────────────────────────────────── */
|
|
279
|
+
.ob-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(5, 7, 12, .55); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); display: grid; place-items: center; padding: 16px; opacity: 0; transition: opacity .3s var(--ease); }
|
|
280
|
+
.ob-backdrop.open { opacity: 1; }
|
|
281
|
+
.ob-backdrop[hidden] { display: none; }
|
|
282
|
+
.ob { width: min(760px, 100%); max-height: calc(100vh - 32px); max-height: calc(100dvh - 32px); display: flex; flex-direction: column; background: var(--surface-solid); border: 1px solid var(--border-strong); border-radius: 20px; box-shadow: 0 30px 80px -20px rgba(0,0,0,.6); transform: translateY(16px) scale(.98); transition: transform .4s var(--ease); overflow: hidden; }
|
|
283
|
+
.ob-backdrop.open .ob { transform: none; }
|
|
284
|
+
.ob-top { padding: 18px 20px 0; display: flex; align-items: center; gap: 12px; }
|
|
285
|
+
.ob-steps { display: flex; gap: 6px; flex: 1; }
|
|
286
|
+
.ob-steps button { flex: 1; height: 6px; padding: 0; border: 0; border-radius: 3px; background: var(--surface-2); cursor: pointer; position: relative; overflow: hidden; }
|
|
287
|
+
.ob-steps button::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, var(--accent), var(--accent-2)); transform: scaleX(0); transform-origin: 0 50%; transition: transform .45s var(--ease); }
|
|
288
|
+
.ob-steps button.done::after { transform: scaleX(1); }
|
|
289
|
+
.ob-body { padding: 18px 20px; overflow: auto; flex: 1; position: relative; }
|
|
290
|
+
.ob-step { display: none; }
|
|
291
|
+
.ob-step.active { display: block; animation: stepIn .4s var(--ease) both; }
|
|
292
|
+
.ob-step.back.active { animation-name: stepInBack; }
|
|
293
|
+
@keyframes stepIn { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
|
|
294
|
+
@keyframes stepInBack { from { opacity: 0; transform: translateX(-24px); } to { opacity: 1; transform: none; } }
|
|
295
|
+
.ob h3 { font-size: 20px; margin: 4px 0 4px; letter-spacing: -.02em; }
|
|
296
|
+
.ob .lead { color: var(--muted); margin: 0 0 16px; }
|
|
297
|
+
.ob-eyebrow { color: var(--accent); font-size: 12px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; }
|
|
298
|
+
.ob-foot { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--border); background: var(--surface); }
|
|
299
|
+
.ob-foot .count { color: var(--faint); font-size: 12.5px; margin-right: auto; }
|
|
300
|
+
.status-line { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 13px; min-height: 22px; }
|
|
301
|
+
.status-line.ok { color: var(--ok); } .status-line.bad { color: var(--bad); } .status-line.info { color: var(--muted); }
|
|
302
|
+
.mini-srv { display: grid; gap: 8px; }
|
|
303
|
+
.mini-srv .item { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 11px; background: var(--surface-2); border: 1px solid var(--border); animation: rise .4s var(--ease) both; }
|
|
304
|
+
.mini-srv .item b { font-size: 13.5px; }
|
|
305
|
+
.mini-srv .item .meta { color: var(--faint); font-size: 12px; margin-left: auto; white-space: nowrap; }
|
|
306
|
+
.hero-art { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 6px 0 16px; }
|
|
307
|
+
.hero-art div { background: var(--surface-2); border: 1px solid var(--border); border-radius: 12px; padding: 12px; font-size: 12.5px; color: var(--muted); }
|
|
308
|
+
.hero-art b { display: block; color: var(--text); font-size: 13.5px; margin: 6px 0 2px; }
|
|
309
|
+
.hero-art svg { width: 22px; height: 22px; color: var(--accent); }
|
|
310
|
+
|
|
311
|
+
/* ── Toast ───────────────────────────────────────────── */
|
|
312
|
+
.toasts { position: fixed; z-index: 60; bottom: 20px; left: 50%; transform: translateX(-50%); display: grid; gap: 8px; pointer-events: none; width: min(420px, calc(100% - 32px)); }
|
|
313
|
+
.toast { pointer-events: auto; padding: 10px 14px; border-radius: 12px; background: var(--surface-solid); border: 1px solid var(--border-strong); box-shadow: var(--shadow); font-size: 13px; display: flex; gap: 10px; align-items: center; animation: toastIn .35s var(--spring) both; }
|
|
314
|
+
.toast.out { animation: toastOut .25s var(--ease) both; }
|
|
315
|
+
@keyframes toastIn { from { opacity: 0; transform: translateY(12px) scale(.96); } to { opacity: 1; transform: none; } }
|
|
316
|
+
@keyframes toastOut { to { opacity: 0; transform: translateY(8px) scale(.98); } }
|
|
317
|
+
|
|
318
|
+
/* ── Key dialog ──────────────────────────────────────── */
|
|
319
|
+
.popover { position: absolute; right: 16px; top: 54px; z-index: 30; width: min(360px, calc(100vw - 32px)); background: var(--surface-solid); border: 1px solid var(--border-strong); border-radius: 14px; box-shadow: var(--shadow); padding: 14px; display: grid; gap: 10px; transform-origin: top right; animation: pop .25s var(--ease) both; }
|
|
320
|
+
.popover[hidden] { display: none; }
|
|
321
|
+
|
|
322
|
+
/* ── Bottom nav (mobile) ─────────────────────────────── */
|
|
323
|
+
.bottom-nav { display: none; }
|
|
324
|
+
|
|
325
|
+
@media (max-width: 1100px) {
|
|
326
|
+
.kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
327
|
+
.filters { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
328
|
+
}
|
|
329
|
+
@media (max-width: 900px) {
|
|
330
|
+
.two, .three { grid-template-columns: minmax(0, 1fr); }
|
|
331
|
+
.pg { grid-template-columns: minmax(0, 1fr); }
|
|
332
|
+
.tool-list { max-height: 220px; }
|
|
333
|
+
}
|
|
334
|
+
@media (max-width: 760px) {
|
|
335
|
+
.tabs { display: none; }
|
|
336
|
+
.brand small, .hide-sm { display: none; }
|
|
337
|
+
.topbar { gap: 8px; }
|
|
338
|
+
main { padding: 14px 12px calc(88px + env(safe-area-inset-bottom)); }
|
|
339
|
+
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
340
|
+
.kpis .kpi:first-child { grid-column: 1 / -1; }
|
|
341
|
+
.kpi .value { font-size: 24px; }
|
|
342
|
+
.card { padding: 14px; }
|
|
343
|
+
.filters { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
344
|
+
.hero-art { grid-template-columns: 1fr; gap: 8px; }
|
|
345
|
+
.hero-art div { display: grid; grid-template-columns: 22px 1fr; column-gap: 12px; align-items: center; padding: 10px 12px; }
|
|
346
|
+
.hero-art div svg { grid-row: span 2; }
|
|
347
|
+
.hero-art div b { margin: 0; }
|
|
348
|
+
.hero-art div > span { grid-column: 2; }
|
|
349
|
+
.pill { padding: 4px 8px; }
|
|
350
|
+
.chart { height: 160px; }
|
|
351
|
+
.bottom-nav { display: grid; grid-template-columns: repeat(5, 1fr); position: fixed; z-index: 25; left: 0; right: 0; bottom: 0; padding: 6px 6px calc(6px + env(safe-area-inset-bottom)); background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); border-top: 1px solid var(--border); }
|
|
352
|
+
.bottom-nav button { border: 0; background: none; display: grid; justify-items: center; gap: 2px; padding: 6px 2px; font-size: 10.5px; color: var(--muted); border-radius: 10px; cursor: pointer; transition: color .2s, transform .15s var(--ease); min-height: 48px; }
|
|
353
|
+
.bottom-nav button svg { width: 21px; height: 21px; }
|
|
354
|
+
.bottom-nav button[aria-selected="true"] { color: var(--accent); }
|
|
355
|
+
.bottom-nav button:active { transform: scale(.92); }
|
|
356
|
+
.ob { border-radius: 18px; }
|
|
357
|
+
.ob-body { padding: 16px; }
|
|
358
|
+
.ob-foot { padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); }
|
|
359
|
+
.history-table { display: none; }
|
|
360
|
+
.history-cards { display: grid !important; }
|
|
361
|
+
}
|
|
362
|
+
.history-cards { display: none; gap: 8px; }
|
|
363
|
+
.hc { padding: 10px 12px; border-radius: 11px; background: var(--surface-2); display: grid; gap: 4px; font-size: 12.5px; }
|
|
364
|
+
.hc .top { display: flex; gap: 8px; align-items: center; }
|
|
365
|
+
.hc .top b { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
366
|
+
.hc .top .badge { margin-left: auto; }
|
|
367
|
+
.hc .sub { color: var(--faint); display: flex; gap: 10px; flex-wrap: wrap; }
|
|
368
|
+
|
|
369
|
+
@media (prefers-reduced-motion: reduce) {
|
|
370
|
+
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
|
|
371
|
+
}
|
|
372
|
+
</style>
|
|
124
373
|
</head>
|
|
125
|
-
<body>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<button class="
|
|
374
|
+
<body id="app">
|
|
375
|
+
<a class="skip" href="#main" data-i18n="skip">Skip to content</a>
|
|
376
|
+
|
|
377
|
+
<header class="topbar">
|
|
378
|
+
<div class="brand">
|
|
379
|
+
<div class="logo" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 32 32"><path d="M7 24V8l9 9 9-9v16" stroke="#fff" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
|
380
|
+
<span>mcp-gateway <small id="version"></small></span>
|
|
381
|
+
</div>
|
|
382
|
+
<nav class="tabs" role="tablist" aria-label="Sections" id="tabs">
|
|
383
|
+
<span class="tab-ink" id="tabInk" aria-hidden="true"></span>
|
|
384
|
+
</nav>
|
|
385
|
+
<div class="spacer"></div>
|
|
386
|
+
<span class="pill" id="livePill" role="status" aria-live="polite"><span class="dot" id="liveDot"></span><span id="liveText" class="hide-sm">…</span></span>
|
|
387
|
+
<button class="icon-btn" id="guideBtn" type="button" data-i18n-title="guide" title="Guide">
|
|
388
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14M12 17.5h.01"/></svg>
|
|
389
|
+
</button>
|
|
390
|
+
<button class="icon-btn" id="keyBtn" type="button" data-i18n-title="apiKey" title="API key" aria-haspopup="dialog" aria-expanded="false">
|
|
391
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M15 8l2 2"/></svg>
|
|
392
|
+
</button>
|
|
393
|
+
<button class="icon-btn lang-btn" id="langBtn" type="button" aria-label="Language / 语言">中</button>
|
|
394
|
+
<button class="icon-btn" id="themeBtn" type="button" data-i18n-title="theme" title="Theme">
|
|
395
|
+
<svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
396
|
+
</button>
|
|
139
397
|
</header>
|
|
140
398
|
|
|
141
|
-
<
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
399
|
+
<div class="popover" id="keyPop" role="dialog" aria-labelledby="keyPopTitle" hidden>
|
|
400
|
+
<b id="keyPopTitle" data-i18n="apiKey">API key</b>
|
|
401
|
+
<input class="input mono" id="keyInput" type="password" autocomplete="off" spellcheck="false" data-i18n-ph="keyPh" placeholder="API key or JWT" />
|
|
402
|
+
<label class="switch"><input type="checkbox" id="keyRemember" /><span data-i18n="remember">Remember on this device</span></label>
|
|
403
|
+
<div class="row"><button class="btn primary sm" id="keySave" type="button" data-i18n="saveConnect">Save & connect</button><button class="btn ghost sm" id="keyClear" type="button" data-i18n="clear">Clear</button></div>
|
|
404
|
+
<div class="status-line info" id="keyStatus"></div>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<main id="main" tabindex="-1">
|
|
408
|
+
<!-- Overview -->
|
|
409
|
+
<section class="view" id="view-overview" role="tabpanel" aria-labelledby="tab-overview">
|
|
410
|
+
<div class="page-head">
|
|
411
|
+
<div><h1 data-i18n="ovTitle">Overview</h1><p data-i18n="ovSub">Live traffic through the gateway</p></div>
|
|
412
|
+
<div class="seg" role="group" data-i18n-aria="window" aria-label="Time window" id="windowSeg">
|
|
413
|
+
<button type="button" data-w="300000">5m</button><button type="button" data-w="900000" aria-pressed="true">15m</button><button type="button" data-w="3600000">1h</button><button type="button" data-w="21600000">6h</button>
|
|
414
|
+
</div>
|
|
149
415
|
</div>
|
|
150
|
-
<div class="
|
|
151
|
-
|
|
152
|
-
<
|
|
153
|
-
|
|
416
|
+
<div class="grid kpis stagger" id="kpis"></div>
|
|
417
|
+
<div class="grid two" style="margin-top:14px">
|
|
418
|
+
<div class="card">
|
|
419
|
+
<h2><span data-i18n="reqRate">Request rate</span><span class="right" id="rateNote"></span></h2>
|
|
420
|
+
<div class="chart-wrap" id="rateChart"></div>
|
|
421
|
+
<div class="legend"><span><i style="background:var(--accent)"></i><span data-i18n="requests">Requests</span></span><span><i style="background:var(--bad)"></i><span data-i18n="errors">Errors</span></span></div>
|
|
422
|
+
</div>
|
|
423
|
+
<div class="card">
|
|
424
|
+
<h2><span data-i18n="latency">Latency</span><span class="right">ms</span></h2>
|
|
425
|
+
<div class="chart-wrap" id="latChart"></div>
|
|
426
|
+
<div class="legend"><span><i style="background:var(--accent-2)"></i>p50</span><span><i style="background:var(--accent)"></i>p95</span></div>
|
|
427
|
+
</div>
|
|
154
428
|
</div>
|
|
155
|
-
<div class="
|
|
156
|
-
<
|
|
157
|
-
<span class="
|
|
158
|
-
<span class="
|
|
429
|
+
<div class="grid three" style="margin-top:14px">
|
|
430
|
+
<div class="card"><h2><span data-i18n="topTools">Top tools</span></h2><div class="bars" id="topTools"></div></div>
|
|
431
|
+
<div class="card"><h2><span data-i18n="perKey">Usage per key</span></h2><div class="bars" id="perKey"></div></div>
|
|
432
|
+
<div class="card"><h2><span data-i18n="errRate">Error rate</span><span class="right" id="errNote"></span></h2><div class="chart-wrap" id="errChart"></div></div>
|
|
159
433
|
</div>
|
|
160
|
-
<div class="
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
434
|
+
<div class="grid two" style="margin-top:14px">
|
|
435
|
+
<div class="card">
|
|
436
|
+
<h2><span data-i18n="liveStream">Live requests</span><span class="right"><label class="switch"><input type="checkbox" id="pauseStream" /><span data-i18n="pause">Pause</span></label></span></h2>
|
|
437
|
+
<ul class="stream" id="stream" aria-live="off"></ul>
|
|
438
|
+
</div>
|
|
439
|
+
<div class="card"><h2><span data-i18n="health">Server health</span><span class="right" id="healthNote"></span></h2><div class="mini-srv" id="healthMini"></div></div>
|
|
164
440
|
</div>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
441
|
+
</section>
|
|
442
|
+
|
|
443
|
+
<!-- Servers -->
|
|
444
|
+
<section class="view" id="view-servers" role="tabpanel" aria-labelledby="tab-servers">
|
|
445
|
+
<div class="page-head"><div><h1 data-i18n="srvTitle">Servers & tools</h1><p data-i18n="srvSub">Upstream MCP servers behind this gateway</p></div>
|
|
446
|
+
<button class="btn sm" id="srvRefresh" type="button"><svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg><span data-i18n="refresh">Refresh</span></button></div>
|
|
447
|
+
<div class="grid servers stagger" id="servers"></div>
|
|
448
|
+
</section>
|
|
449
|
+
|
|
450
|
+
<!-- Playground -->
|
|
451
|
+
<section class="view" id="view-playground" role="tabpanel" aria-labelledby="tab-playground">
|
|
452
|
+
<div class="page-head"><div><h1 data-i18n="pgTitle">Playground</h1><p data-i18n="pgSub">Pick a tool, fill the form generated from its schema, and call it</p></div></div>
|
|
453
|
+
<div class="pg" id="pgMain"></div>
|
|
454
|
+
</section>
|
|
455
|
+
|
|
456
|
+
<!-- History -->
|
|
457
|
+
<section class="view" id="view-history" role="tabpanel" aria-labelledby="tab-history">
|
|
458
|
+
<div class="page-head"><div><h1 data-i18n="hiTitle">Request history</h1><p id="hiSource" data-i18n="hiSub">Every call through REST and /mcp</p></div></div>
|
|
459
|
+
<div class="card">
|
|
460
|
+
<form class="filters" id="hiFilters">
|
|
461
|
+
<div class="field"><label for="f-server" data-i18n="server">Server</label><select class="input" id="f-server" name="server"><option value="" data-i18n="any">Any</option></select></div>
|
|
462
|
+
<div class="field"><label for="f-tool" data-i18n="tool">Tool</label><input class="input" id="f-tool" name="tool" autocomplete="off" /></div>
|
|
463
|
+
<div class="field"><label for="f-client" data-i18n="client">Client</label><input class="input" id="f-client" name="client" autocomplete="off" placeholder="key:…" /></div>
|
|
464
|
+
<div class="field"><label for="f-success" data-i18n="result">Result</label><select class="input" id="f-success" name="success"><option value="" data-i18n="any">Any</option><option value="true" data-i18n="ok">OK</option><option value="false" data-i18n="failed">Failed</option></select></div>
|
|
465
|
+
<div class="field"><label for="f-via" data-i18n="via">Via</label><select class="input" id="f-via" name="via"><option value="" data-i18n="any">Any</option><option value="rest">REST</option><option value="mcp">/mcp</option></select></div>
|
|
466
|
+
<div class="field"><label for="f-kind" data-i18n="kind">Kind</label><select class="input" id="f-kind" name="kind"><option value="" data-i18n="any">Any</option><option value="tool">tool</option><option value="resource">resource</option><option value="prompt">prompt</option></select></div>
|
|
467
|
+
<div class="row"><button class="btn primary" type="submit" data-i18n="apply">Apply</button><button class="btn ghost" type="reset" data-i18n="reset">Reset</button></div>
|
|
468
|
+
</form>
|
|
169
469
|
</div>
|
|
170
|
-
<div class="
|
|
171
|
-
<
|
|
172
|
-
<
|
|
173
|
-
<span class="
|
|
470
|
+
<div class="card" style="margin-top:14px">
|
|
471
|
+
<div class="table-wrap history-table"><table><thead><tr><th data-i18n="time">Time</th><th data-i18n="tool">Tool</th><th data-i18n="server">Server</th><th data-i18n="client">Client</th><th data-i18n="via">Via</th><th data-i18n="duration">Duration</th><th data-i18n="result">Result</th></tr></thead><tbody id="hiBody"></tbody></table></div>
|
|
472
|
+
<div class="history-cards" id="hiCards"></div>
|
|
473
|
+
<div class="pager"><span id="hiInfo"></span><div class="row"><button class="btn sm" id="hiPrev" type="button" data-i18n="prev">← Newer</button><button class="btn sm" id="hiNext" type="button" data-i18n="next">Older →</button></div></div>
|
|
174
474
|
</div>
|
|
175
|
-
</
|
|
475
|
+
</section>
|
|
176
476
|
|
|
177
|
-
<!--
|
|
178
|
-
<
|
|
179
|
-
<div class="
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
477
|
+
<!-- Connect -->
|
|
478
|
+
<section class="view" id="view-connect" role="tabpanel" aria-labelledby="tab-connect">
|
|
479
|
+
<div class="page-head"><div><h1 data-i18n="cnTitle">Connect a client</h1><p data-i18n="cnSub">Point any MCP client at this gateway</p></div></div>
|
|
480
|
+
<div class="card" id="connectCard"></div>
|
|
481
|
+
</section>
|
|
482
|
+
</main>
|
|
483
|
+
|
|
484
|
+
<nav class="bottom-nav" id="bottomNav" role="tablist" aria-label="Sections"></nav>
|
|
485
|
+
|
|
486
|
+
<!-- Onboarding -->
|
|
487
|
+
<div class="ob-backdrop" id="ob" hidden>
|
|
488
|
+
<div class="ob" role="dialog" aria-modal="true" aria-labelledby="obTitle">
|
|
489
|
+
<div class="ob-top">
|
|
490
|
+
<div class="ob-steps" id="obSteps"></div>
|
|
491
|
+
<button class="icon-btn" id="obClose" type="button" data-i18n-title="close" title="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
|
|
187
492
|
</div>
|
|
188
|
-
<div class="
|
|
189
|
-
<div class="
|
|
190
|
-
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
493
|
+
<div class="ob-body" id="obBody">
|
|
494
|
+
<div class="ob-step" data-step="0">
|
|
495
|
+
<div class="ob-eyebrow" data-i18n="ob1Eye">Step 1 · Connect</div>
|
|
496
|
+
<h3 id="obTitle" data-i18n="ob1Title">Welcome to your MCP gateway</h3>
|
|
497
|
+
<p class="lead" data-i18n="ob1Lead">One endpoint for every MCP server — with auth, rate limits and live metrics. Let's get you connected in under a minute.</p>
|
|
498
|
+
<div class="hero-art">
|
|
499
|
+
<div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/></svg><b data-i18n="hero1">Many servers</b><span data-i18n="hero1s">stdio, SSE, WebSocket, HTTP</span></div>
|
|
500
|
+
<div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7z"/></svg><b data-i18n="hero2">One secure door</b><span data-i18n="hero2s">keys, scopes, limits</span></div>
|
|
501
|
+
<div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 17l5-5 4 4 8-8"/></svg><b data-i18n="hero3">Live insight</b><span data-i18n="hero3s">latency, errors, usage</span></div>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="field"><label for="obKey" data-i18n="obKeyLabel">API key (leave empty if auth is off)</label>
|
|
504
|
+
<div class="row" style="flex-wrap:nowrap"><input class="input mono" id="obKey" type="password" autocomplete="off" spellcheck="false" data-i18n-ph="keyPh" placeholder="API key or JWT" /><button class="btn primary" id="obTest" type="button"><svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg><span data-i18n="test">Test</span></button></div>
|
|
505
|
+
</div>
|
|
506
|
+
<label class="switch" style="margin-top:10px"><input type="checkbox" id="obRemember" /><span data-i18n="remember">Remember on this device</span></label>
|
|
507
|
+
<div class="status-line info" id="obKeyStatus" role="status" aria-live="polite"></div>
|
|
508
|
+
</div>
|
|
509
|
+
<div class="ob-step" data-step="1">
|
|
510
|
+
<div class="ob-eyebrow" data-i18n="ob2Eye">Step 2 · Upstream servers</div>
|
|
511
|
+
<h3 data-i18n="ob2Title">These servers are behind the gateway</h3>
|
|
512
|
+
<p class="lead" data-i18n="ob2Lead">Each one is health-checked continuously and reconnected with backoff when it drops.</p>
|
|
513
|
+
<div class="mini-srv" id="obServers"></div>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="ob-step" data-step="2">
|
|
516
|
+
<div class="ob-eyebrow" data-i18n="ob3Eye">Step 3 · Try a tool</div>
|
|
517
|
+
<h3 data-i18n="ob3Title">Make your first tool call</h3>
|
|
518
|
+
<p class="lead" data-i18n="ob3Lead">We listed every tool you can use. Pick one — the form is generated from its JSON schema.</p>
|
|
519
|
+
<div id="obPg"></div>
|
|
520
|
+
</div>
|
|
521
|
+
<div class="ob-step" data-step="3">
|
|
522
|
+
<div class="ob-eyebrow" data-i18n="ob4Eye">Step 4 · Connect your client</div>
|
|
523
|
+
<h3 data-i18n="ob4Title">Use it from your favourite tool</h3>
|
|
524
|
+
<p class="lead" data-i18n="ob4Lead">Copy a snippet. Every client sees all upstream tools through this one URL.</p>
|
|
525
|
+
<div id="obSnips"></div>
|
|
526
|
+
</div>
|
|
527
|
+
<div class="ob-step" data-step="4">
|
|
528
|
+
<div class="ob-eyebrow" data-i18n="ob5Eye">All set</div>
|
|
529
|
+
<h3 data-i18n="ob5Title">You're ready 🎉</h3>
|
|
530
|
+
<p class="lead" data-i18n="ob5Lead">The dashboard updates live as requests flow through. Reopen this guide any time from the ? button.</p>
|
|
531
|
+
<div class="hero-art">
|
|
532
|
+
<div><b data-i18n="tabOverview">Overview</b><span data-i18n="ob5a">Rates, latency, errors, keys</span></div>
|
|
533
|
+
<div><b data-i18n="tabPlayground">Playground</b><span data-i18n="ob5b">Call any tool from the browser</span></div>
|
|
534
|
+
<div><b data-i18n="tabHistory">History</b><span data-i18n="ob5c">Filter and page every request</span></div>
|
|
535
|
+
</div>
|
|
195
536
|
</div>
|
|
196
537
|
</div>
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
<form class="filters" id="req-filters" onsubmit="event.preventDefault(); loadRequests(true)">
|
|
203
|
-
<input id="f-server" placeholder="server id" aria-label="Filter by server" size="12" />
|
|
204
|
-
<input id="f-tool" placeholder="tool / URI / prompt" aria-label="Filter by tool" size="16" />
|
|
205
|
-
<select id="f-status" aria-label="Filter by status"><option value="">any status</option><option value="true">ok</option><option value="false">error</option></select>
|
|
206
|
-
<select id="f-via" aria-label="Filter by interface"><option value="">REST + MCP</option><option value="rest">REST</option><option value="mcp">MCP</option></select>
|
|
207
|
-
<select id="f-since" aria-label="Time range"><option value="">all time</option><option value="3600000">last hour</option><option value="86400000">last 24 h</option><option value="604800000">last 7 days</option></select>
|
|
208
|
-
<button class="refresh-btn" type="submit">Apply</button>
|
|
209
|
-
<span class="source" id="req-source"></span>
|
|
210
|
-
</form>
|
|
211
|
-
<div class="table-wrap">
|
|
212
|
-
<table>
|
|
213
|
-
<thead><tr><th>Time</th><th>Via</th><th>Tool / resource / prompt</th><th>Server</th><th>Client</th><th>Duration</th><th>Status</th></tr></thead>
|
|
214
|
-
<tbody id="requests-tbody"><tr><td colspan="7" class="loader"><span class="spinner"></span></td></tr></tbody>
|
|
215
|
-
</table>
|
|
538
|
+
<div class="ob-foot">
|
|
539
|
+
<span class="count" id="obCount"></span>
|
|
540
|
+
<button class="btn ghost" id="obSkip" type="button" data-i18n="skipGuide">Skip</button>
|
|
541
|
+
<button class="btn" id="obBack" type="button" data-i18n="back">Back</button>
|
|
542
|
+
<button class="btn primary" id="obNext" type="button" data-i18n="nextStep">Next</button>
|
|
216
543
|
</div>
|
|
217
|
-
<button class="refresh-btn more" id="req-more" type="button" hidden onclick="loadRequests(false)">Load older</button>
|
|
218
544
|
</div>
|
|
219
|
-
</
|
|
545
|
+
</div>
|
|
220
546
|
|
|
221
|
-
<
|
|
222
|
-
const BASE = window.location.origin;
|
|
223
|
-
const KEY_NAME = 'mcp-gateway.apiKey';
|
|
224
|
-
const keyInput = document.getElementById('api-key');
|
|
225
|
-
const remember = document.getElementById('remember');
|
|
226
|
-
const notice = document.getElementById('auth-notice');
|
|
227
|
-
let authMissing = false;
|
|
228
|
-
|
|
229
|
-
function getKey() {
|
|
230
|
-
return sessionStorage.getItem(KEY_NAME) || localStorage.getItem(KEY_NAME) || '';
|
|
231
|
-
}
|
|
232
|
-
function setKey(key, persist) {
|
|
233
|
-
sessionStorage.removeItem(KEY_NAME);
|
|
234
|
-
localStorage.removeItem(KEY_NAME);
|
|
235
|
-
if (key) (persist ? localStorage : sessionStorage).setItem(KEY_NAME, key);
|
|
236
|
-
}
|
|
237
|
-
remember.checked = !!localStorage.getItem(KEY_NAME);
|
|
238
|
-
keyInput.value = getKey() ? '••••••••' : '';
|
|
239
|
-
|
|
240
|
-
document.getElementById('auth-form').addEventListener('submit', (e) => {
|
|
241
|
-
e.preventDefault();
|
|
242
|
-
const v = keyInput.value.trim();
|
|
243
|
-
if (v && v !== '••••••••') setKey(v, remember.checked);
|
|
244
|
-
keyInput.value = getKey() ? '••••••••' : '';
|
|
245
|
-
loadAll();
|
|
246
|
-
});
|
|
247
|
-
document.getElementById('clear-key').addEventListener('click', () => {
|
|
248
|
-
setKey('', false);
|
|
249
|
-
keyInput.value = '';
|
|
250
|
-
loadAll();
|
|
251
|
-
});
|
|
547
|
+
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
252
548
|
|
|
253
|
-
|
|
549
|
+
<script>
|
|
550
|
+
(() => {
|
|
551
|
+
'use strict';
|
|
254
552
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
553
|
+
// ─── i18n ────────────────────────────────────────────────────────────────────
|
|
554
|
+
const I18N = {
|
|
555
|
+
en: {
|
|
556
|
+
skip: 'Skip to content', guide: 'Guide', apiKey: 'API key', theme: 'Toggle theme', keyPh: 'API key or JWT',
|
|
557
|
+
remember: 'Remember on this device', saveConnect: 'Save & connect', clear: 'Clear', close: 'Close',
|
|
558
|
+
tabOverview: 'Overview', tabServers: 'Servers', tabPlayground: 'Playground', tabHistory: 'History', tabConnect: 'Connect',
|
|
559
|
+
ovTitle: 'Overview', ovSub: 'Live traffic through the gateway', window: 'Time window',
|
|
560
|
+
reqRate: 'Request rate', requests: 'Requests', errors: 'Errors', latency: 'Latency', topTools: 'Top tools', perKey: 'Usage per key',
|
|
561
|
+
errRate: 'Error rate', liveStream: 'Live requests', pause: 'Pause', health: 'Server health',
|
|
562
|
+
kRpm: 'Requests / min', kP50: 'p50 latency', kP95: 'p95 latency', kErr: 'Error rate', kSrv: 'Servers online',
|
|
563
|
+
kTotal: '{n} in window', kP99: 'p99 {v}', kErrN: '{n} failed', kTools: '{n} tools',
|
|
564
|
+
srvTitle: 'Servers & tools', srvSub: 'Upstream MCP servers behind this gateway', refresh: 'Refresh', reconnect: 'Reconnect',
|
|
565
|
+
pgTitle: 'Playground', pgSub: 'Pick a tool, fill the form generated from its schema, and call it',
|
|
566
|
+
hiTitle: 'Request history', hiSub: 'Every call through REST and /mcp', server: 'Server', tool: 'Tool', client: 'Client', result: 'Result',
|
|
567
|
+
via: 'Via', kind: 'Kind', any: 'Any', ok: 'OK', failed: 'Failed', apply: 'Apply', reset: 'Reset', time: 'Time', duration: 'Duration',
|
|
568
|
+
prev: '← Newer', next: 'Older →', page: 'Page {n}', srcAudit: 'Persistent audit log', srcMemory: 'In-memory log',
|
|
569
|
+
cnTitle: 'Connect a client', cnSub: 'Point any MCP client at this gateway',
|
|
570
|
+
ob1Eye: 'Step 1 · Connect', ob1Title: 'Welcome to your MCP gateway',
|
|
571
|
+
ob1Lead: "One endpoint for every MCP server — with auth, rate limits and live metrics. Let's get you connected in under a minute.",
|
|
572
|
+
hero1: 'Many servers', hero1s: 'stdio, SSE, WebSocket, HTTP', hero2: 'One secure door', hero2s: 'keys, scopes, limits', hero3: 'Live insight', hero3s: 'latency, errors, usage',
|
|
573
|
+
obKeyLabel: 'API key (leave empty if auth is off)', test: 'Test',
|
|
574
|
+
ob2Eye: 'Step 2 · Upstream servers', ob2Title: 'These servers are behind the gateway', ob2Lead: 'Each one is health-checked continuously and reconnected with backoff when it drops.',
|
|
575
|
+
ob3Eye: 'Step 3 · Try a tool', ob3Title: 'Make your first tool call', ob3Lead: 'We listed every tool you can use. Pick one — the form is generated from its JSON schema.',
|
|
576
|
+
ob4Eye: 'Step 4 · Connect your client', ob4Title: 'Use it from your favourite tool', ob4Lead: 'Copy a snippet. Every client sees all upstream tools through this one URL.',
|
|
577
|
+
ob5Eye: 'All set', ob5Title: "You're ready 🎉", ob5Lead: 'The dashboard updates live as requests flow through. Reopen this guide any time from the ? button.',
|
|
578
|
+
ob5a: 'Rates, latency, errors, keys', ob5b: 'Call any tool from the browser', ob5c: 'Filter and page every request',
|
|
579
|
+
skipGuide: 'Skip', back: 'Back', nextStep: 'Next', finish: 'Open dashboard', stepOf: 'Step {a} of {b}',
|
|
580
|
+
connOk: 'Connected — your key was accepted', connOkNoAuth: 'Connected — this gateway does not require a key', connBad: 'That key was rejected (401)',
|
|
581
|
+
connErr: 'Could not reach the gateway: {e}', checking: 'Checking…',
|
|
582
|
+
live: 'Live', polling: 'Polling', offline: 'Offline', needKey: 'Key needed',
|
|
583
|
+
searchTools: 'Search tools…', noTools: 'No tools available for this key.', noServers: 'No servers configured yet. Add some to mcp-gateway.yml.',
|
|
584
|
+
pickTool: 'Pick a tool on the left to get started.', call: 'Call tool', rawJson: 'Edit as JSON', args: 'Arguments', noArgs: 'This tool takes no arguments.',
|
|
585
|
+
invalidJson: 'Invalid JSON', required: 'Required', callOk: 'Success · {ms} ms', callBad: 'Failed · HTTP {s}', asCurl: 'As curl',
|
|
586
|
+
copied: 'Copied to clipboard', copy: 'Copy', includeKey: 'Insert my key into snippets', mcpUrl: 'MCP endpoint',
|
|
587
|
+
snipClaudeNote: 'Claude Desktop speaks stdio, so this bridges through mcp-remote. File: claude_desktop_config.json',
|
|
588
|
+
snipCursorNote: 'File: ~/.cursor/mcp.json (or .cursor/mcp.json in a project)',
|
|
589
|
+
snipJsNote: 'Uses the bundled TypeScript client (clients/js) — works in browsers, Node 18+, Deno and Bun.',
|
|
590
|
+
snipKotlinNote: 'JVM / Android (clients/kotlin). Calls are blocking — run them on Dispatchers.IO.',
|
|
591
|
+
snipCurlNote: 'Raw JSON-RPC over Streamable HTTP. Keep the Mcp-Session-Id header from the first response.',
|
|
592
|
+
snipCodeNote: 'Claude Code CLI adds the gateway as a remote HTTP server.',
|
|
593
|
+
noData: 'No requests in this window yet', waiting: 'Waiting for requests…', noKeys: 'No client usage yet',
|
|
594
|
+
status_online: 'online', status_degraded: 'degraded', status_reconnecting: 'reconnecting', status_offline: 'offline', status_unknown: 'unknown',
|
|
595
|
+
transport: 'Transport', ping: 'Ping', toolsN: 'Tools', nextRetry: 'retry in {s}s', reconnected: 'Reconnected {id}', reconnectFail: 'Reconnect failed',
|
|
596
|
+
authRequired: 'This gateway needs an API key.', setKey: 'Set key', perMin: '/min', nothing: '—', serverCount: '{a}/{b} online',
|
|
597
|
+
loadErr: 'Failed to load: {e}', truncated: 'showing first {n}'
|
|
598
|
+
},
|
|
599
|
+
zh: {
|
|
600
|
+
skip: '跳到内容', guide: '新手引导', apiKey: 'API 密钥', theme: '切换主题', keyPh: 'API 密钥或 JWT',
|
|
601
|
+
remember: '在此设备上记住', saveConnect: '保存并连接', clear: '清除', close: '关闭',
|
|
602
|
+
tabOverview: '总览', tabServers: '服务器', tabPlayground: '调试台', tabHistory: '历史', tabConnect: '接入',
|
|
603
|
+
ovTitle: '总览', ovSub: '网关实时流量', window: '时间范围',
|
|
604
|
+
reqRate: '请求速率', requests: '请求', errors: '错误', latency: '延迟', topTools: '热门工具', perKey: '各密钥用量',
|
|
605
|
+
errRate: '错误率', liveStream: '实时请求', pause: '暂停', health: '服务器健康',
|
|
606
|
+
kRpm: '每分钟请求', kP50: 'p50 延迟', kP95: 'p95 延迟', kErr: '错误率', kSrv: '在线服务器',
|
|
607
|
+
kTotal: '窗口内 {n} 次', kP99: 'p99 {v}', kErrN: '{n} 次失败', kTools: '{n} 个工具',
|
|
608
|
+
srvTitle: '服务器与工具', srvSub: '网关背后的上游 MCP 服务器', refresh: '刷新', reconnect: '重新连接',
|
|
609
|
+
pgTitle: '调试台', pgSub: '选择工具,按其 JSON Schema 自动生成表单并调用',
|
|
610
|
+
hiTitle: '请求历史', hiSub: '经由 REST 和 /mcp 的每一次调用', server: '服务器', tool: '工具', client: '客户端', result: '结果',
|
|
611
|
+
via: '通道', kind: '类型', any: '全部', ok: '成功', failed: '失败', apply: '应用', reset: '重置', time: '时间', duration: '耗时',
|
|
612
|
+
prev: '← 较新', next: '较早 →', page: '第 {n} 页', srcAudit: '持久化审计日志', srcMemory: '内存日志',
|
|
613
|
+
cnTitle: '接入客户端', cnSub: '让任意 MCP 客户端连接到本网关',
|
|
614
|
+
ob1Eye: '第 1 步 · 连接', ob1Title: '欢迎使用 MCP 网关',
|
|
615
|
+
ob1Lead: '一个入口接入所有 MCP 服务器——自带鉴权、限流与实时监控。一分钟内即可完成连接。',
|
|
616
|
+
hero1: '多个服务器', hero1s: 'stdio、SSE、WebSocket、HTTP', hero2: '统一安全入口', hero2s: '密钥、权限范围、限流', hero3: '实时洞察', hero3s: '延迟、错误、用量',
|
|
617
|
+
obKeyLabel: 'API 密钥(未开启鉴权可留空)', test: '测试',
|
|
618
|
+
ob2Eye: '第 2 步 · 上游服务器', ob2Title: '这些服务器已接入网关', ob2Lead: '网关会持续进行健康检查,断开后按退避策略自动重连。',
|
|
619
|
+
ob3Eye: '第 3 步 · 试用工具', ob3Title: '发起第一次工具调用', ob3Lead: '下面列出了你可以使用的全部工具。选一个——表单会根据它的 JSON Schema 自动生成。',
|
|
620
|
+
ob4Eye: '第 4 步 · 接入客户端', ob4Title: '在常用工具中使用', ob4Lead: '复制一段配置即可。所有客户端都通过这一个 URL 看到全部上游工具。',
|
|
621
|
+
ob5Eye: '完成', ob5Title: '一切就绪 🎉', ob5Lead: '请求经过时,仪表盘会实时更新。随时可以点击 ? 按钮重新打开本引导。',
|
|
622
|
+
ob5a: '速率、延迟、错误、密钥', ob5b: '在浏览器中调用任意工具', ob5c: '筛选并分页查看每个请求',
|
|
623
|
+
skipGuide: '跳过', back: '上一步', nextStep: '下一步', finish: '进入仪表盘', stepOf: '第 {a} / {b} 步',
|
|
624
|
+
connOk: '已连接——密钥验证通过', connOkNoAuth: '已连接——此网关无需密钥', connBad: '密钥被拒绝(401)',
|
|
625
|
+
connErr: '无法连接网关:{e}', checking: '检查中…',
|
|
626
|
+
live: '实时', polling: '轮询中', offline: '离线', needKey: '需要密钥',
|
|
627
|
+
searchTools: '搜索工具…', noTools: '此密钥没有可用的工具。', noServers: '尚未配置服务器,请在 mcp-gateway.yml 中添加。',
|
|
628
|
+
pickTool: '从左侧选择一个工具开始。', call: '调用工具', rawJson: '以 JSON 编辑', args: '参数', noArgs: '此工具不需要参数。',
|
|
629
|
+
invalidJson: 'JSON 格式错误', required: '必填', callOk: '成功 · {ms} ms', callBad: '失败 · HTTP {s}', asCurl: '复制为 curl',
|
|
630
|
+
copied: '已复制到剪贴板', copy: '复制', includeKey: '在示例中填入我的密钥', mcpUrl: 'MCP 端点',
|
|
631
|
+
snipClaudeNote: 'Claude Desktop 使用 stdio,因此通过 mcp-remote 桥接。配置文件:claude_desktop_config.json',
|
|
632
|
+
snipCursorNote: '配置文件:~/.cursor/mcp.json(或项目内 .cursor/mcp.json)',
|
|
633
|
+
snipJsNote: '使用仓库自带的 TypeScript 客户端(clients/js),支持浏览器、Node 18+、Deno 和 Bun。',
|
|
634
|
+
snipKotlinNote: 'JVM / Android(clients/kotlin)。调用是阻塞的,请在 Dispatchers.IO 上执行。',
|
|
635
|
+
snipCurlNote: '基于 Streamable HTTP 的原始 JSON-RPC。请保留首个响应中的 Mcp-Session-Id 头。',
|
|
636
|
+
snipCodeNote: '用 Claude Code CLI 将网关添加为远程 HTTP 服务器。',
|
|
637
|
+
noData: '此时间范围内暂无请求', waiting: '等待请求…', noKeys: '暂无客户端用量',
|
|
638
|
+
status_online: '在线', status_degraded: '降级', status_reconnecting: '重连中', status_offline: '离线', status_unknown: '未知',
|
|
639
|
+
transport: '传输', ping: '延迟', toolsN: '工具', nextRetry: '{s} 秒后重试', reconnected: '已重新连接 {id}', reconnectFail: '重新连接失败',
|
|
640
|
+
authRequired: '此网关需要 API 密钥。', setKey: '设置密钥', perMin: '/分', nothing: '—', serverCount: '{a}/{b} 在线',
|
|
641
|
+
loadErr: '加载失败:{e}', truncated: '仅显示前 {n} 个'
|
|
263
642
|
}
|
|
643
|
+
};
|
|
644
|
+
const LS = { key: 'mcp-gateway.apiKey', lang: 'mcp-gateway.lang', theme: 'mcp-gateway.theme', onboarded: 'mcp-gateway.onboarded.v2' };
|
|
645
|
+
const store = {
|
|
646
|
+
get(k) { try { return localStorage.getItem(k); } catch { return null; } },
|
|
647
|
+
set(k, v) { try { localStorage.setItem(k, v); } catch {} },
|
|
648
|
+
del(k) { try { localStorage.removeItem(k); } catch {} },
|
|
649
|
+
};
|
|
650
|
+
let lang = store.get(LS.lang) || ((navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en');
|
|
651
|
+
const t = (k, vars) => {
|
|
652
|
+
let s = (I18N[lang] && I18N[lang][k]) ?? I18N.en[k] ?? k;
|
|
653
|
+
if (vars) for (const [a, b] of Object.entries(vars)) s = s.replace(`{${a}}`, b);
|
|
654
|
+
return s;
|
|
655
|
+
};
|
|
656
|
+
function applyI18n(root = document) {
|
|
657
|
+
document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en';
|
|
658
|
+
root.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n); });
|
|
659
|
+
root.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = t(el.dataset.i18nTitle); el.setAttribute('aria-label', t(el.dataset.i18nTitle)); });
|
|
660
|
+
root.querySelectorAll('[data-i18n-ph]').forEach((el) => { el.placeholder = t(el.dataset.i18nPh); });
|
|
661
|
+
root.querySelectorAll('[data-i18n-aria]').forEach((el) => { el.setAttribute('aria-label', t(el.dataset.i18nAria)); });
|
|
662
|
+
$('#langBtn').textContent = lang === 'zh' ? 'EN' : '中';
|
|
663
|
+
}
|
|
264
664
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
665
|
+
// ─── Utilities ───────────────────────────────────────────────────────────────
|
|
666
|
+
const $ = (s, r = document) => r.querySelector(s);
|
|
667
|
+
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
|
|
668
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
669
|
+
const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
670
|
+
const fmtMs = (v) => (v == null || !isFinite(v) ? '—' : v >= 10000 ? (v / 1000).toFixed(1) + 's' : v >= 1000 ? (v / 1000).toFixed(2) + 's' : Math.round(v) + 'ms');
|
|
671
|
+
const fmtNum = (v, d = 0) => (v == null || !isFinite(v) ? '—' : v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : v >= 1e4 ? (v / 1e3).toFixed(1) + 'k' : v.toFixed(d));
|
|
672
|
+
const fmtTime = (ts) => new Date(ts).toLocaleTimeString(lang === 'zh' ? 'zh-CN' : undefined, { hour12: false });
|
|
673
|
+
const fmtDateTime = (ts) => new Date(ts).toLocaleString(lang === 'zh' ? 'zh-CN' : undefined, { hour12: false, month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
674
|
+
const statusCls = (s) => ({ online: 'ok', degraded: 'warn', reconnecting: 'info', offline: 'bad' })[s] || '';
|
|
675
|
+
const ORIGIN = location.origin;
|
|
676
|
+
const ICON = {
|
|
677
|
+
overview: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 13h4l3-8 4 14 3-6h4"/></svg>',
|
|
678
|
+
servers: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/></svg>',
|
|
679
|
+
playground: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M7 4v16l13-8z"/></svg>',
|
|
680
|
+
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/></svg>',
|
|
681
|
+
connect: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M9 15l6-6M10.5 6.5l1.8-1.8a4 4 0 0 1 5.7 5.7l-1.8 1.8M13.5 17.5l-1.8 1.8a4 4 0 0 1-5.7-5.7l1.8-1.8"/></svg>',
|
|
682
|
+
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',
|
|
683
|
+
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5 9-10"/></svg>',
|
|
684
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
|
685
|
+
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
|
686
|
+
empty: '<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 13l3-8h12l3 8v6H3z"/><path d="M3 13h5l1 2h6l1-2h5"/></svg>',
|
|
687
|
+
spin: '<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-3-6.7"/></svg>',
|
|
688
|
+
};
|
|
269
689
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
690
|
+
function toast(msg, ms = 2200) {
|
|
691
|
+
const el = document.createElement('div');
|
|
692
|
+
el.className = 'toast';
|
|
693
|
+
el.innerHTML = `<span style="color:var(--ok)">${ICON.check.replace('<svg', '<svg width="16" height="16"')}</span><span>${esc(msg)}</span>`;
|
|
694
|
+
$('#toasts').append(el);
|
|
695
|
+
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 260); }, ms);
|
|
696
|
+
}
|
|
697
|
+
async function copyText(text, btn) {
|
|
698
|
+
try { await navigator.clipboard.writeText(text); }
|
|
699
|
+
catch {
|
|
700
|
+
const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
701
|
+
document.body.append(ta); ta.select(); try { document.execCommand('copy'); } catch {} ta.remove();
|
|
274
702
|
}
|
|
703
|
+
toast(t('copied'));
|
|
704
|
+
if (btn) { const old = btn.innerHTML; btn.innerHTML = ICON.check; setTimeout(() => (btn.innerHTML = old), 1200); }
|
|
705
|
+
}
|
|
706
|
+
/** Animate a number in an element (text only; cheap). */
|
|
707
|
+
function tween(el, to, fmt) {
|
|
708
|
+
const from = Number(el.dataset.v ?? to);
|
|
709
|
+
el.dataset.v = to;
|
|
710
|
+
if (reduced() || from === to || !isFinite(from) || !isFinite(to)) { el.innerHTML = fmt(to); return; }
|
|
711
|
+
const start = performance.now(), dur = 500;
|
|
712
|
+
cancelAnimationFrame(el._raf);
|
|
713
|
+
const step = (now) => {
|
|
714
|
+
const p = Math.min(1, (now - start) / dur), e = 1 - Math.pow(1 - p, 3);
|
|
715
|
+
el.innerHTML = fmt(from + (to - from) * e);
|
|
716
|
+
if (p < 1) el._raf = requestAnimationFrame(step);
|
|
717
|
+
};
|
|
718
|
+
el._raf = requestAnimationFrame(step);
|
|
719
|
+
}
|
|
720
|
+
/** Swap DOM with a View Transition where supported. */
|
|
721
|
+
function transition(fn) {
|
|
722
|
+
if (document.startViewTransition && !reduced()) document.startViewTransition(fn);
|
|
723
|
+
else fn();
|
|
724
|
+
}
|
|
275
725
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
726
|
+
// ─── Theme ───────────────────────────────────────────────────────────────────
|
|
727
|
+
function setTheme(theme, save) {
|
|
728
|
+
document.documentElement.dataset.theme = theme;
|
|
729
|
+
$('#themeIcon').innerHTML = theme === 'dark' ? ICON.sun : ICON.moon;
|
|
730
|
+
$('meta[name="theme-color"]').content = theme === 'dark' ? '#0b0d12' : '#f5f6fa';
|
|
731
|
+
if (save) store.set(LS.theme, theme);
|
|
732
|
+
}
|
|
733
|
+
setTheme(store.get(LS.theme) || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'));
|
|
734
|
+
$('#themeBtn').addEventListener('click', () => transition(() => {
|
|
735
|
+
setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark', true);
|
|
736
|
+
renderCharts();
|
|
737
|
+
}));
|
|
285
738
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
739
|
+
// ─── API ─────────────────────────────────────────────────────────────────────
|
|
740
|
+
function getKey() { try { return sessionStorage.getItem(LS.key) || store.get(LS.key) || ''; } catch { return store.get(LS.key) || ''; } }
|
|
741
|
+
function setKey(key, remember) {
|
|
742
|
+
try { sessionStorage.removeItem(LS.key); } catch {}
|
|
743
|
+
store.del(LS.key);
|
|
744
|
+
if (!key) return;
|
|
745
|
+
if (remember) store.set(LS.key, key); else try { sessionStorage.setItem(LS.key, key); } catch {}
|
|
746
|
+
}
|
|
747
|
+
function headers(extra = {}) {
|
|
748
|
+
const k = getKey();
|
|
749
|
+
return k ? { ...extra, Authorization: `Bearer ${k}` } : extra;
|
|
750
|
+
}
|
|
751
|
+
class HttpError extends Error { constructor(status, body) { super(body?.message || body?.error || `HTTP ${status}`); this.status = status; this.body = body; } }
|
|
752
|
+
async function api(path, opts = {}) {
|
|
753
|
+
const r = await fetch(`/api/v1${path}`, { ...opts, headers: headers(opts.headers), cache: 'no-store' });
|
|
754
|
+
let body = null;
|
|
755
|
+
const text = await r.text();
|
|
756
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = { message: text }; }
|
|
757
|
+
if (r.status === 401) { state.auth = 'needed'; updateLive(); }
|
|
758
|
+
if (!r.ok && r.status !== 207) throw new HttpError(r.status, body);
|
|
759
|
+
return body;
|
|
760
|
+
}
|
|
290
761
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
762
|
+
// ─── State ───────────────────────────────────────────────────────────────────
|
|
763
|
+
const state = {
|
|
764
|
+
view: 'overview',
|
|
765
|
+
auth: 'unknown', // unknown | ok | needed
|
|
766
|
+
live: 'connecting', // live | polling | offline | connecting
|
|
767
|
+
windowMs: 900000,
|
|
768
|
+
stats: null,
|
|
769
|
+
servers: [],
|
|
770
|
+
tools: [],
|
|
771
|
+
health: null,
|
|
772
|
+
root: null,
|
|
773
|
+
stream: [],
|
|
774
|
+
hist: { cursors: [undefined], page: 0, filters: {}, next: null },
|
|
775
|
+
pg: { tool: null, raw: false, filter: '' },
|
|
776
|
+
snip: 'cursor',
|
|
777
|
+
includeKey: false,
|
|
778
|
+
};
|
|
306
779
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
780
|
+
// ─── Navigation ──────────────────────────────────────────────────────────────
|
|
781
|
+
const VIEWS = ['overview', 'servers', 'playground', 'history', 'connect'];
|
|
782
|
+
function buildNav() {
|
|
783
|
+
const tabs = $('#tabs'), bottom = $('#bottomNav');
|
|
784
|
+
tabs.querySelectorAll('.tab').forEach((b) => b.remove());
|
|
785
|
+
bottom.innerHTML = '';
|
|
786
|
+
for (const v of VIEWS) {
|
|
787
|
+
const label = t('tab' + v[0].toUpperCase() + v.slice(1));
|
|
788
|
+
const a = document.createElement('button');
|
|
789
|
+
a.type = 'button'; a.className = 'tab'; a.role = 'tab'; a.id = `tab-${v}`; a.dataset.view = v;
|
|
790
|
+
a.setAttribute('aria-controls', `view-${v}`);
|
|
791
|
+
a.innerHTML = esc(label);
|
|
792
|
+
tabs.append(a);
|
|
793
|
+
const b = document.createElement('button');
|
|
794
|
+
b.type = 'button'; b.role = 'tab'; b.dataset.view = v;
|
|
795
|
+
b.innerHTML = ICON[v] + `<span>${esc(label)}</span>`;
|
|
796
|
+
bottom.append(b);
|
|
797
|
+
}
|
|
798
|
+
$$('[data-view]').forEach((b) => b.addEventListener('click', () => go(b.dataset.view)));
|
|
799
|
+
tabs.addEventListener('keydown', (e) => {
|
|
800
|
+
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
|
|
801
|
+
const i = VIEWS.indexOf(state.view) + (e.key === 'ArrowRight' ? 1 : -1);
|
|
802
|
+
const v = VIEWS[(i + VIEWS.length) % VIEWS.length];
|
|
803
|
+
go(v); $(`#tab-${v}`).focus();
|
|
804
|
+
});
|
|
805
|
+
markNav();
|
|
806
|
+
}
|
|
807
|
+
function markNav() {
|
|
808
|
+
$$('[data-view]').forEach((b) => {
|
|
809
|
+
const on = b.dataset.view === state.view;
|
|
810
|
+
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
811
|
+
b.tabIndex = on ? 0 : -1;
|
|
812
|
+
});
|
|
813
|
+
const active = $(`#tab-${state.view}`), ink = $('#tabInk');
|
|
814
|
+
if (active && active.offsetWidth) {
|
|
815
|
+
ink.style.width = '10px';
|
|
816
|
+
ink.style.transform = `translateX(${active.offsetLeft}px) scaleX(${active.offsetWidth / 10})`;
|
|
329
817
|
}
|
|
818
|
+
}
|
|
819
|
+
function go(view, push = true) {
|
|
820
|
+
if (!VIEWS.includes(view)) view = 'overview';
|
|
821
|
+
if (view === state.view && $(`#view-${view}`).classList.contains('active')) return;
|
|
822
|
+
state.view = view;
|
|
823
|
+
transition(() => {
|
|
824
|
+
$$('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${view}`));
|
|
825
|
+
markNav();
|
|
826
|
+
});
|
|
827
|
+
if (push) history.replaceState(null, '', `#${view}`);
|
|
828
|
+
if (view === 'servers') loadServers();
|
|
829
|
+
if (view === 'playground') renderPlayground($('#pgMain'), 'main');
|
|
830
|
+
if (view === 'history') loadHistory();
|
|
831
|
+
if (view === 'connect') renderSnippets($('#connectCard'), 'main');
|
|
832
|
+
if (view === 'overview') requestAnimationFrame(renderCharts);
|
|
833
|
+
}
|
|
834
|
+
window.addEventListener('hashchange', () => go(location.hash.slice(1), false));
|
|
835
|
+
window.addEventListener('resize', () => { markNav(); scheduleCharts(); });
|
|
330
836
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
837
|
+
// ─── Live connection (SSE over fetch → fallback polling) ─────────────────────
|
|
838
|
+
let sseAbort = null, pollTimer = null, sseRetry = 0, statsTimer = null, statsPending = false;
|
|
839
|
+
function updateLive() {
|
|
840
|
+
const dot = $('#liveDot'), txt = $('#liveText');
|
|
841
|
+
const m = state.auth === 'needed' ? ['bad', t('needKey')] : state.live === 'live' ? ['ok', t('live')] : state.live === 'polling' ? ['warn', t('polling')] : state.live === 'offline' ? ['bad', t('offline')] : ['', '…'];
|
|
842
|
+
dot.className = 'dot ' + m[0];
|
|
843
|
+
txt.textContent = m[1];
|
|
844
|
+
}
|
|
845
|
+
function startLive() {
|
|
846
|
+
stopLive();
|
|
847
|
+
if (state.auth === 'needed') return;
|
|
848
|
+
connectSse();
|
|
849
|
+
}
|
|
850
|
+
function stopLive() {
|
|
851
|
+
sseAbort?.abort(); sseAbort = null;
|
|
852
|
+
clearInterval(pollTimer); pollTimer = null;
|
|
853
|
+
}
|
|
854
|
+
async function connectSse() {
|
|
855
|
+
const ac = new AbortController();
|
|
856
|
+
sseAbort = ac;
|
|
857
|
+
try {
|
|
858
|
+
const r = await fetch(`/api/v1/events?window=${state.windowMs}`, { headers: headers({ Accept: 'text/event-stream' }), signal: ac.signal, cache: 'no-store' });
|
|
859
|
+
if (r.status === 401) { state.auth = 'needed'; updateLive(); return; }
|
|
860
|
+
if (!r.ok || !r.body) throw new Error('HTTP ' + r.status);
|
|
861
|
+
state.live = 'live'; sseRetry = 0; updateLive();
|
|
862
|
+
clearInterval(pollTimer); pollTimer = null;
|
|
863
|
+
const reader = r.body.getReader(), dec = new TextDecoder();
|
|
864
|
+
let buf = '';
|
|
865
|
+
for (;;) {
|
|
866
|
+
const { value, done } = await reader.read();
|
|
867
|
+
if (done) break;
|
|
868
|
+
buf += dec.decode(value, { stream: true });
|
|
869
|
+
let i;
|
|
870
|
+
while ((i = buf.indexOf('\n\n')) >= 0) {
|
|
871
|
+
const block = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
872
|
+
let ev = 'message', data = '';
|
|
873
|
+
for (const line of block.split('\n')) {
|
|
874
|
+
if (line.startsWith('event: ')) ev = line.slice(7);
|
|
875
|
+
else if (line.startsWith('data: ')) data += line.slice(6);
|
|
876
|
+
}
|
|
877
|
+
if (data) { try { onEvent(ev, JSON.parse(data)); } catch {} }
|
|
338
878
|
}
|
|
339
|
-
tbody.innerHTML = data.servers.map(s => {
|
|
340
|
-
const h = s.health || {};
|
|
341
|
-
const err = h.errorMessage ? ` title="${esc(h.errorMessage)}"` : '';
|
|
342
|
-
return `
|
|
343
|
-
<tr>
|
|
344
|
-
<td><code>${esc(s.id)}</code></td>
|
|
345
|
-
<td>${esc(s.name)}</td>
|
|
346
|
-
<td><span class="badge healthy">${esc(s.transport ?? 'stdio')}</span></td>
|
|
347
|
-
<td>${esc(s.toolCount ?? 0)}</td>
|
|
348
|
-
<td${err}>${statusBadge(h.status)}${reconnectInfo(h)}</td>
|
|
349
|
-
</tr>`;
|
|
350
|
-
}).join('');
|
|
351
|
-
} catch (e) {
|
|
352
|
-
tbody.innerHTML = `<tr><td colspan="5" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load servers'}</td></tr>`;
|
|
353
879
|
}
|
|
880
|
+
throw new Error('stream closed');
|
|
881
|
+
} catch (e) {
|
|
882
|
+
if (ac.signal.aborted) return;
|
|
883
|
+
// Fall back to polling every 2s, and retry the stream with backoff.
|
|
884
|
+
state.live = 'polling'; updateLive();
|
|
885
|
+
if (!pollTimer) pollTimer = setInterval(pollTick, 2000);
|
|
886
|
+
const delay = Math.min(30000, 2000 * 2 ** sseRetry++);
|
|
887
|
+
setTimeout(() => { if (sseAbort === ac) connectSse(); }, delay);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function onEvent(ev, data) {
|
|
891
|
+
if (ev === 'request') {
|
|
892
|
+
pushStream(data);
|
|
893
|
+
scheduleStats();
|
|
894
|
+
} else if (ev === 'snapshot') {
|
|
895
|
+
if (data.health) { state.healthList = data.health; renderHealthMini(); }
|
|
354
896
|
}
|
|
897
|
+
}
|
|
898
|
+
async function pollTick() {
|
|
899
|
+
try {
|
|
900
|
+
const before = state.stats?.summary?.total;
|
|
901
|
+
await loadStats();
|
|
902
|
+
const recent = await api('/requests?limit=20');
|
|
903
|
+
const known = new Set(state.stream.map((r) => r.id));
|
|
904
|
+
recent.requests.slice().reverse().forEach((r) => { if (!known.has(r.id)) pushStream(r); });
|
|
905
|
+
if (state.live !== 'live') { state.live = 'polling'; updateLive(); }
|
|
906
|
+
if (before === undefined) renderOverview();
|
|
907
|
+
} catch (e) {
|
|
908
|
+
if (state.live !== 'live') { state.live = 'offline'; updateLive(); }
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
function scheduleStats() {
|
|
912
|
+
if (statsTimer) { statsPending = true; return; }
|
|
913
|
+
loadStats();
|
|
914
|
+
statsTimer = setTimeout(() => { statsTimer = null; if (statsPending) { statsPending = false; scheduleStats(); } }, 1000);
|
|
915
|
+
}
|
|
355
916
|
|
|
356
|
-
|
|
917
|
+
// ─── Data loading ────────────────────────────────────────────────────────────
|
|
918
|
+
async function loadRoot() {
|
|
919
|
+
try { state.root = await (await fetch('/', { cache: 'no-store' })).json(); } catch { state.root = {}; }
|
|
920
|
+
try {
|
|
921
|
+
const h = await api('/health');
|
|
922
|
+
state.health = h;
|
|
923
|
+
$('#version').textContent = h.version ? 'v' + h.version : '';
|
|
924
|
+
} catch {}
|
|
925
|
+
}
|
|
926
|
+
async function loadStats() {
|
|
927
|
+
const s = await api(`/stats?window=${state.windowMs}`);
|
|
928
|
+
state.stats = s;
|
|
929
|
+
renderOverview();
|
|
930
|
+
return s;
|
|
931
|
+
}
|
|
932
|
+
async function loadServers() {
|
|
933
|
+
const btn = $('#srvRefresh'); btn.classList.add('loading');
|
|
934
|
+
try {
|
|
935
|
+
const r = await api('/servers');
|
|
936
|
+
state.servers = r.servers;
|
|
937
|
+
state.healthList = r.servers.map((s) => ({ ...(s.health || {}), serverId: s.id }));
|
|
938
|
+
renderServers(); renderHealthMini(); fillServerFilter();
|
|
939
|
+
} catch (e) {
|
|
940
|
+
if (!state.servers.length) $('#servers').innerHTML = `<div class="card empty">${esc(e.status === 401 ? t('authRequired') : t('loadErr', { e: e.message }))}</div>`;
|
|
941
|
+
} finally { btn.classList.remove('loading'); }
|
|
942
|
+
}
|
|
943
|
+
async function loadTools() {
|
|
944
|
+
const r = await api('/tools');
|
|
945
|
+
state.tools = r.tools;
|
|
946
|
+
return r.tools;
|
|
947
|
+
}
|
|
357
948
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
949
|
+
// ─── Overview rendering ──────────────────────────────────────────────────────
|
|
950
|
+
function kpiCard(id, label, sub) {
|
|
951
|
+
return `<div class="card kpi" id="k-${id}"><div class="label">${esc(label)}</div><div class="value" data-v="0">—</div><div class="sub">${sub}</div><svg class="spark" viewBox="0 0 84 30" preserveAspectRatio="none" aria-hidden="true"></svg></div>`;
|
|
952
|
+
}
|
|
953
|
+
function renderKpiShell() {
|
|
954
|
+
$('#kpis').innerHTML = [
|
|
955
|
+
kpiCard('rpm', t('kRpm'), ' '), kpiCard('p50', t('kP50'), ' '), kpiCard('p95', t('kP95'), ' '),
|
|
956
|
+
kpiCard('err', t('kErr'), ' '), kpiCard('srv', t('kSrv'), ' '),
|
|
957
|
+
].join('');
|
|
958
|
+
$$('#kpis .value').forEach((v) => v.classList.add('sk'));
|
|
959
|
+
}
|
|
960
|
+
function spark(svg, values, color) {
|
|
961
|
+
if (!svg) return;
|
|
962
|
+
const max = Math.max(1, ...values), n = values.length;
|
|
963
|
+
if (n < 2) { svg.innerHTML = ''; return; }
|
|
964
|
+
const pts = values.map((v, i) => `${(i / (n - 1)) * 84},${28 - (v / max) * 26}`);
|
|
965
|
+
svg.innerHTML = `<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>`;
|
|
966
|
+
}
|
|
967
|
+
function renderOverview() {
|
|
968
|
+
const s = state.stats;
|
|
969
|
+
if (!s) return;
|
|
970
|
+
const set = (id, val, fmt, sub) => {
|
|
971
|
+
const card = $(`#k-${id}`); if (!card) return;
|
|
972
|
+
const v = $('.value', card); v.classList.remove('sk');
|
|
973
|
+
tween(v, val, fmt);
|
|
974
|
+
if (sub !== undefined) $('.sub', card).textContent = sub;
|
|
975
|
+
};
|
|
976
|
+
const sm = s.summary, series = s.series;
|
|
977
|
+
const perMinFactor = 60000 / s.bucketMs;
|
|
978
|
+
set('rpm', sm.requestsPerMinute, (x) => fmtNum(x, x < 10 ? 1 : 0), t('kTotal', { n: sm.total }));
|
|
979
|
+
set('p50', sm.p50, (x) => fmtMs(x).replace(/(ms|s)$/, '<small>$1</small>'));
|
|
980
|
+
set('p95', sm.p95, (x) => fmtMs(x).replace(/(ms|s)$/, '<small>$1</small>'), t('kP99', { v: fmtMs(sm.p99) }));
|
|
981
|
+
set('err', sm.errorRate * 100, (x) => x.toFixed(x < 10 ? 1 : 0) + '<small>%</small>', t('kErrN', { n: sm.errors }));
|
|
982
|
+
spark($('#k-rpm .spark'), series.map((b) => b.count * perMinFactor), 'var(--accent)');
|
|
983
|
+
spark($('#k-p50 .spark'), series.map((b) => b.p50), 'var(--accent-2)');
|
|
984
|
+
spark($('#k-p95 .spark'), series.map((b) => b.p95), 'var(--accent)');
|
|
985
|
+
spark($('#k-err .spark'), series.map((b) => (b.count ? b.errors / b.count : 0)), 'var(--bad)');
|
|
986
|
+
$('#k-err').style.borderColor = sm.errorRate > 0.05 ? 'color-mix(in srgb, var(--bad) 45%, transparent)' : '';
|
|
987
|
+
renderServerKpi();
|
|
988
|
+
$('#rateNote').textContent = `${fmtNum(sm.requestsPerMinute, 1)} ${t('perMin')}`;
|
|
989
|
+
$('#errNote').textContent = (sm.errorRate * 100).toFixed(1) + '%';
|
|
990
|
+
renderBars($('#topTools'), s.tools.map((x) => ({ name: x.name, tag: x.serverId, count: x.count, errors: x.errors, extra: 'p95 ' + fmtMs(x.p95) })), t('noData'));
|
|
991
|
+
renderBars($('#perKey'), s.clients.map((x) => ({ name: x.id, count: x.count, errors: x.errors, extra: fmtTime(x.lastSeen) })), t('noKeys'));
|
|
992
|
+
scheduleCharts();
|
|
993
|
+
}
|
|
994
|
+
function renderServerKpi() {
|
|
995
|
+
const list = state.healthList || [];
|
|
996
|
+
const card = $('#k-srv'); if (!card) return;
|
|
997
|
+
const online = list.filter((h) => h.status === 'online').length;
|
|
998
|
+
const v = $('.value', card); v.classList.remove('sk');
|
|
999
|
+
v.innerHTML = `${online}<small>/ ${list.length}</small>`;
|
|
1000
|
+
const tools = state.health?.servers?.totalTools;
|
|
1001
|
+
$('.sub', card).textContent = tools != null ? t('kTools', { n: tools }) : '';
|
|
1002
|
+
card.style.borderColor = list.some((h) => h.status === 'offline') ? 'color-mix(in srgb, var(--bad) 45%, transparent)' : list.some((h) => h.status !== 'online') ? 'color-mix(in srgb, var(--warn) 45%, transparent)' : '';
|
|
1003
|
+
}
|
|
1004
|
+
function renderBars(el, rows, emptyText) {
|
|
1005
|
+
if (!rows.length) { el.innerHTML = `<div class="empty">${ICON.empty}${esc(emptyText)}</div>`; return; }
|
|
1006
|
+
const max = Math.max(...rows.map((r) => r.count));
|
|
1007
|
+
const html = rows.slice(0, 8).map((r) => `
|
|
1008
|
+
<div class="bar-row" title="${esc(r.name)}">
|
|
1009
|
+
<div class="name">${esc(r.name)}${r.tag ? ` <span>· ${esc(r.tag)}</span>` : ''}</div>
|
|
1010
|
+
<div class="num">${fmtNum(r.count)}${r.errors ? ` · <span style="color:var(--bad)">${r.errors}✕</span>` : ''} · ${esc(r.extra)}</div>
|
|
1011
|
+
<div class="bar-track"><div class="bar-fill${r.errors / r.count > 0.2 ? ' err' : ''}" data-s="${(r.count / max).toFixed(4)}"></div></div>
|
|
1012
|
+
</div>`).join('');
|
|
1013
|
+
// Keep existing bars to animate from their old scale.
|
|
1014
|
+
const old = new Map($$('.bar-row', el).map((row) => [row.title, $('.bar-fill', row).style.transform]));
|
|
1015
|
+
el.innerHTML = html;
|
|
1016
|
+
$$('.bar-row', el).forEach((row) => { const f = $('.bar-fill', row); if (old.has(row.title)) f.style.transform = old.get(row.title); });
|
|
1017
|
+
requestAnimationFrame(() => requestAnimationFrame(() => $$('.bar-fill', el).forEach((f) => (f.style.transform = `scaleX(${f.dataset.s})`))));
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// ─── SVG charts ──────────────────────────────────────────────────────────────
|
|
1021
|
+
let chartRaf = 0;
|
|
1022
|
+
function scheduleCharts() { cancelAnimationFrame(chartRaf); chartRaf = requestAnimationFrame(renderCharts); }
|
|
1023
|
+
function niceMax(v) {
|
|
1024
|
+
if (v <= 0) return 1;
|
|
1025
|
+
const p = Math.pow(10, Math.floor(Math.log10(v))), n = v / p;
|
|
1026
|
+
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * p;
|
|
1027
|
+
}
|
|
1028
|
+
function renderCharts() {
|
|
1029
|
+
const s = state.stats;
|
|
1030
|
+
if (!s || state.view !== 'overview') return;
|
|
1031
|
+
const perMin = 60000 / s.bucketMs;
|
|
1032
|
+
drawChart($('#rateChart'), s.series, [
|
|
1033
|
+
{ key: (b) => b.count * perMin, color: 'var(--accent)', area: true, label: t('requests'), fmt: (v) => fmtNum(v, 1) + t('perMin') },
|
|
1034
|
+
{ key: (b) => b.errors * perMin, color: 'var(--bad)', label: t('errors'), fmt: (v) => fmtNum(v, 1) + t('perMin') },
|
|
1035
|
+
], (v) => fmtNum(v, v < 10 ? 1 : 0));
|
|
1036
|
+
drawChart($('#latChart'), s.series, [
|
|
1037
|
+
{ key: (b) => b.p95, color: 'var(--accent)', area: true, label: 'p95', fmt: fmtMs, gap: (b) => !b.count },
|
|
1038
|
+
{ key: (b) => b.p50, color: 'var(--accent-2)', label: 'p50', fmt: fmtMs, gap: (b) => !b.count },
|
|
1039
|
+
], fmtMs);
|
|
1040
|
+
drawChart($('#errChart'), s.series, [
|
|
1041
|
+
{ key: (b) => (b.count ? (b.errors / b.count) * 100 : 0), color: 'var(--bad)', area: true, label: t('errRate'), fmt: (v) => v.toFixed(1) + '%' },
|
|
1042
|
+
], (v) => Math.round(v) + '%', 100);
|
|
1043
|
+
}
|
|
1044
|
+
function drawChart(host, series, lines, yFmt, fixedMax) {
|
|
1045
|
+
if (!host) return;
|
|
1046
|
+
const W = Math.max(240, host.clientWidth || 600), H = host.clientWidth < 500 ? 160 : 190;
|
|
1047
|
+
const padL = 44, padR = 8, padT = 10, padB = 22;
|
|
1048
|
+
const iw = W - padL - padR, ih = H - padT - padB, n = series.length;
|
|
1049
|
+
let max = 0;
|
|
1050
|
+
for (const l of lines) for (const b of series) max = Math.max(max, l.key(b));
|
|
1051
|
+
max = fixedMax ?? niceMax(max * 1.1);
|
|
1052
|
+
const x = (i) => padL + (n <= 1 ? iw / 2 : (i / (n - 1)) * iw);
|
|
1053
|
+
const y = (v) => padT + ih - (Math.min(v, max) / max) * ih;
|
|
1054
|
+
const uid = host.id;
|
|
1055
|
+
let svg = `<svg class="chart" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${esc(lines.map((l) => l.label).join(', '))}"><defs>`;
|
|
1056
|
+
lines.forEach((l, k) => { svg += `<linearGradient id="g-${uid}-${k}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${l.color}" stop-opacity=".28"/><stop offset="1" stop-color="${l.color}" stop-opacity="0"/></linearGradient>`; });
|
|
1057
|
+
svg += '</defs>';
|
|
1058
|
+
for (let g = 0; g <= 4; g++) {
|
|
1059
|
+
const v = (max / 4) * g, yy = y(v);
|
|
1060
|
+
svg += `<line class="gl" x1="${padL}" x2="${W - padR}" y1="${yy}" y2="${yy}"/><text x="${padL - 6}" y="${yy + 3.5}" text-anchor="end">${esc(yFmt(v))}</text>`;
|
|
1061
|
+
}
|
|
1062
|
+
const ticks = Math.min(6, n);
|
|
1063
|
+
for (let k = 0; k < ticks; k++) {
|
|
1064
|
+
const i = Math.round((k / Math.max(1, ticks - 1)) * (n - 1));
|
|
1065
|
+
if (series[i]) svg += `<text x="${x(i)}" y="${H - 6}" text-anchor="${k === 0 ? 'start' : k === ticks - 1 ? 'end' : 'middle'}">${esc(fmtTime(series[i].t).slice(0, 5))}</text>`;
|
|
1066
|
+
}
|
|
1067
|
+
lines.forEach((l, k) => {
|
|
1068
|
+
// Smooth path with Catmull-Rom → Bezier; buckets without data are skipped for latency.
|
|
1069
|
+
const pts = series.map((b, i) => (l.gap && l.gap(b) ? null : [x(i), y(l.key(b))]));
|
|
1070
|
+
const segs = []; let cur = [];
|
|
1071
|
+
for (const p of pts) { if (p) cur.push(p); else if (cur.length) { segs.push(cur); cur = []; } }
|
|
1072
|
+
if (cur.length) segs.push(cur);
|
|
1073
|
+
for (const seg of segs) {
|
|
1074
|
+
const d = smooth(seg);
|
|
1075
|
+
if (l.area && seg.length > 1) svg += `<path d="${d} L${seg[seg.length - 1][0]},${padT + ih} L${seg[0][0]},${padT + ih} Z" fill="url(#g-${uid}-${k})"/>`;
|
|
1076
|
+
if (seg.length === 1) svg += `<circle cx="${seg[0][0]}" cy="${seg[0][1]}" r="2.5" fill="${l.color}"/>`;
|
|
1077
|
+
else svg += `<path d="${d}" fill="none" stroke="${l.color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
|
|
375
1078
|
}
|
|
1079
|
+
});
|
|
1080
|
+
svg += `<line id="cx-${uid}" x1="0" x2="0" y1="${padT}" y2="${padT + ih}" stroke="var(--border-strong)" stroke-dasharray="3 3" opacity="0"/>`;
|
|
1081
|
+
svg += `<rect x="${padL}" y="${padT}" width="${iw}" height="${ih}" fill="transparent" data-hit="1"/></svg><div class="chart-tip" role="presentation"></div>`;
|
|
1082
|
+
host.innerHTML = svg;
|
|
1083
|
+
const hit = $('[data-hit]', host), tip = $('.chart-tip', host), cross = $(`#cx-${uid}`, host);
|
|
1084
|
+
const show = (clientX) => {
|
|
1085
|
+
const rect = host.getBoundingClientRect();
|
|
1086
|
+
const px = ((clientX - rect.left) / rect.width) * W;
|
|
1087
|
+
const i = Math.max(0, Math.min(n - 1, Math.round(((px - padL) / iw) * (n - 1))));
|
|
1088
|
+
const b = series[i]; if (!b) return;
|
|
1089
|
+
cross.setAttribute('x1', x(i)); cross.setAttribute('x2', x(i)); cross.setAttribute('opacity', '1');
|
|
1090
|
+
tip.innerHTML = `<b>${esc(fmtTime(b.t))}</b><br>` + lines.map((l) => `<span style="color:${l.color}">●</span> ${esc(l.label)}: ${esc(l.fmt(l.key(b)))}`).join('<br>');
|
|
1091
|
+
tip.classList.add('show');
|
|
1092
|
+
const left = (x(i) / W) * rect.width;
|
|
1093
|
+
tip.style.transform = `translate(${Math.min(rect.width - tip.offsetWidth - 4, Math.max(0, left - tip.offsetWidth / 2))}px, 0)`;
|
|
1094
|
+
tip.style.left = '0'; tip.style.top = '-6px';
|
|
1095
|
+
};
|
|
1096
|
+
hit.addEventListener('pointermove', (e) => show(e.clientX));
|
|
1097
|
+
hit.addEventListener('pointerdown', (e) => show(e.clientX));
|
|
1098
|
+
hit.addEventListener('pointerleave', () => { tip.classList.remove('show'); cross.setAttribute('opacity', '0'); });
|
|
1099
|
+
}
|
|
1100
|
+
function smooth(p) {
|
|
1101
|
+
if (p.length < 2) return `M${p[0][0]},${p[0][1]}`;
|
|
1102
|
+
let d = `M${p[0][0].toFixed(1)},${p[0][1].toFixed(1)}`;
|
|
1103
|
+
for (let i = 0; i < p.length - 1; i++) {
|
|
1104
|
+
const p0 = p[i - 1] || p[i], p1 = p[i], p2 = p[i + 1], p3 = p[i + 2] || p2;
|
|
1105
|
+
const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
|
|
1106
|
+
const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
|
|
1107
|
+
const lo = Math.min(p1[1], p2[1]), hi = Math.max(p1[1], p2[1]);
|
|
1108
|
+
d += ` C${c1x.toFixed(1)},${Math.min(hi, Math.max(lo, c1y)).toFixed(1)} ${c2x.toFixed(1)},${Math.min(hi, Math.max(lo, c2y)).toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
|
|
376
1109
|
}
|
|
1110
|
+
return d;
|
|
1111
|
+
}
|
|
377
1112
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1113
|
+
// ─── Live stream ─────────────────────────────────────────────────────────────
|
|
1114
|
+
function streamItem(r, enter) {
|
|
1115
|
+
const li = document.createElement('li');
|
|
1116
|
+
if (enter && !reduced()) li.className = 'enter';
|
|
1117
|
+
li.innerHTML = `<span class="dot ${r.success ? 'ok' : 'bad'}" style="animation:none"></span>
|
|
1118
|
+
<span class="what"><b class="mono">${esc(r.toolName)}</b> <span>· ${esc(r.serverId)}${r.clientId ? ' · ' + esc(r.clientId) : ''}${r.via ? ' · ' + esc(r.via) : ''}</span></span>
|
|
1119
|
+
<span class="meta">${fmtMs(r.durationMs)} · ${fmtTime(r.timestamp)}</span>`;
|
|
1120
|
+
if (!r.success && r.errorMessage) li.title = r.errorMessage;
|
|
1121
|
+
return li;
|
|
1122
|
+
}
|
|
1123
|
+
function pushStream(r) {
|
|
1124
|
+
state.stream.unshift(r);
|
|
1125
|
+
if (state.stream.length > 50) state.stream.length = 50;
|
|
1126
|
+
if ($('#pauseStream').checked) return;
|
|
1127
|
+
const ul = $('#stream');
|
|
1128
|
+
$('.empty', ul)?.remove();
|
|
1129
|
+
ul.prepend(streamItem(r, true));
|
|
1130
|
+
while (ul.children.length > 50) ul.lastElementChild.remove();
|
|
1131
|
+
}
|
|
1132
|
+
function renderStream() {
|
|
1133
|
+
const ul = $('#stream');
|
|
1134
|
+
ul.innerHTML = '';
|
|
1135
|
+
if (!state.stream.length) { ul.innerHTML = `<li class="empty" style="display:block;background:none">${ICON.empty}${esc(t('waiting'))}</li>`; return; }
|
|
1136
|
+
state.stream.forEach((r) => ul.append(streamItem(r, false)));
|
|
1137
|
+
}
|
|
1138
|
+
$('#pauseStream').addEventListener('change', (e) => { if (!e.target.checked) renderStream(); });
|
|
1139
|
+
|
|
1140
|
+
// ─── Servers ─────────────────────────────────────────────────────────────────
|
|
1141
|
+
function healthOf(id) { return (state.healthList || []).find((h) => h.serverId === id) || {}; }
|
|
1142
|
+
function renderHealthMini() {
|
|
1143
|
+
const el = $('#healthMini');
|
|
1144
|
+
const list = state.healthList || [];
|
|
1145
|
+
renderServerKpi();
|
|
1146
|
+
const online = list.filter((h) => h.status === 'online').length;
|
|
1147
|
+
$('#healthNote').textContent = list.length ? t('serverCount', { a: online, b: list.length }) : '';
|
|
1148
|
+
if (!list.length) { el.innerHTML = `<div class="empty">${ICON.empty}${esc(t('noServers'))}</div>`; return; }
|
|
1149
|
+
const html = list.map((h) => `<div class="item" style="animation:none"><span class="dot ${statusCls(h.status)}"></span><b>${esc(h.serverId)}</b><span class="meta">${h.latencyMs != null ? fmtMs(h.latencyMs) : ''}</span><span class="badge ${statusCls(h.status)}">${esc(t('status_' + (h.status || 'unknown')))}</span></div>`).join('');
|
|
1150
|
+
if (el.innerHTML !== html) el.innerHTML = html;
|
|
1151
|
+
}
|
|
1152
|
+
function renderServers() {
|
|
1153
|
+
const el = $('#servers');
|
|
1154
|
+
// Entrance animation only on the first render; refreshes swap in place.
|
|
1155
|
+
if (el.dataset.drawn) el.classList.remove('stagger'); else setTimeout(() => (el.dataset.drawn = '1'), 0);
|
|
1156
|
+
if (!state.servers.length) { el.innerHTML = `<div class="card empty">${ICON.empty}${esc(t('noServers'))}</div>`; return; }
|
|
1157
|
+
el.innerHTML = state.servers.map((s) => {
|
|
1158
|
+
const h = s.health || {}, st = h.status || 'unknown';
|
|
1159
|
+
const retry = h.reconnect?.state === 'scheduled' && h.reconnect.nextAttemptAt ? Math.max(0, Math.round((new Date(h.reconnect.nextAttemptAt) - Date.now()) / 1000)) : null;
|
|
1160
|
+
return `<article class="card srv">
|
|
1161
|
+
<div class="srv-head"><span class="dot ${statusCls(st)}"></span><b title="${esc(s.name || s.id)}">${esc(s.name || s.id)}</b><span class="badge ${statusCls(st)}">${esc(t('status_' + st))}</span></div>
|
|
1162
|
+
<div class="kv"><div><span>${esc(t('transport'))}</span><b>${esc(s.transport)}</b></div><div><span>${esc(t('ping'))}</span><b>${h.latencyMs != null ? fmtMs(h.latencyMs) : '—'}</b></div><div><span>${esc(t('toolsN'))}</span><b>${s.toolCount ?? 0}</b></div></div>
|
|
1163
|
+
${h.errorMessage && st !== 'online' ? `<div class="err">${esc(h.errorMessage)}${retry != null ? ' · ' + esc(t('nextRetry', { s: retry })) : ''}</div>` : ''}
|
|
1164
|
+
${(s.tags || []).length ? `<div class="chips">${s.tags.map((tg) => `<span class="badge">#${esc(tg)}</span>`).join('')}</div>` : ''}
|
|
1165
|
+
<div class="chips" data-tools="${esc(s.id)}"></div>
|
|
1166
|
+
<div class="row"><button class="btn sm" type="button" data-reconnect="${esc(s.id)}">${ICON.spin}<span>${esc(t('reconnect'))}</span></button><button class="btn sm ghost" type="button" data-try="${esc(s.id)}">${ICON.playground.replace('<svg', '<svg width="14" height="14"')}<span>${esc(t('tabPlayground'))}</span></button></div>
|
|
1167
|
+
</article>`;
|
|
1168
|
+
}).join('');
|
|
1169
|
+
// Tool chips (from /tools, filtered to this key's scope).
|
|
1170
|
+
const fill = () => $$('[data-tools]', el).forEach((c) => {
|
|
1171
|
+
const tools = state.tools.filter((x) => x.serverId === c.dataset.tools);
|
|
1172
|
+
c.innerHTML = tools.slice(0, 12).map((x) => `<button type="button" class="chip" data-tool="${esc(key(x))}" title="${esc(x.description || '')}">${esc(x.name)}</button>`).join('') + (tools.length > 12 ? `<span class="badge">+${tools.length - 12}</span>` : '');
|
|
1173
|
+
});
|
|
1174
|
+
if (state.tools.length) fill(); else loadTools().then(fill).catch(() => {});
|
|
1175
|
+
$$('[data-reconnect]', el).forEach((b) => b.addEventListener('click', async () => {
|
|
1176
|
+
b.classList.add('loading'); b.disabled = true;
|
|
1177
|
+
try { await api(`/servers/${encodeURIComponent(b.dataset.reconnect)}/reconnect`, { method: 'POST' }); toast(t('reconnected', { id: b.dataset.reconnect })); }
|
|
1178
|
+
catch (e) { toast(`${t('reconnectFail')}: ${e.message}`, 3500); }
|
|
1179
|
+
finally { b.classList.remove('loading'); b.disabled = false; loadServers(); }
|
|
1180
|
+
}));
|
|
1181
|
+
$$('[data-try]', el).forEach((b) => b.addEventListener('click', () => { const tool = state.tools.find((x) => x.serverId === b.dataset.try); state.pg.tool = tool ? key(tool) : null; go('playground'); }));
|
|
1182
|
+
$$('[data-tool]', el).forEach((b) => b.addEventListener('click', () => { state.pg.tool = b.dataset.tool; go('playground'); }));
|
|
1183
|
+
}
|
|
1184
|
+
$('#srvRefresh').addEventListener('click', () => { state.tools = []; loadServers(); });
|
|
392
1185
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
1186
|
+
// ─── Playground (JSON-schema form) ───────────────────────────────────────────
|
|
1187
|
+
const key = (tool) => `${encodeURIComponent(tool.serverId)}/${encodeURIComponent(tool.name)}`;
|
|
1188
|
+
function schemaOf(tool) { const s = tool?.inputSchema; return s && typeof s === 'object' ? s : { type: 'object', properties: {} }; }
|
|
1189
|
+
function typeOf(p) { const ty = Array.isArray(p.type) ? p.type.find((x) => x !== 'null') : p.type; return ty || (p.enum ? 'string' : p.properties ? 'object' : 'string'); }
|
|
1190
|
+
function fieldHtml(name, p, req, id) {
|
|
1191
|
+
const ty = typeOf(p);
|
|
1192
|
+
const label = `<label for="${id}">${esc(p.title || name)} <span class="type">${esc(ty)}${p.format ? ':' + esc(p.format) : ''}</span>${req ? ` <span class="req" aria-label="${esc(t('required'))}">*</span>` : ''}</label>`;
|
|
1193
|
+
const hint = p.description ? `<div class="hint" id="${id}-h">${esc(p.description)}</div>` : '';
|
|
1194
|
+
const aria = `${p.description ? `aria-describedby="${id}-h"` : ''} ${req ? 'aria-required="true"' : ''}`;
|
|
1195
|
+
const def = p.default;
|
|
1196
|
+
let input;
|
|
1197
|
+
if (Array.isArray(p.enum)) {
|
|
1198
|
+
input = `<select class="input" id="${id}" data-name="${esc(name)}" data-type="enum" ${aria}>${req ? '' : '<option value="">—</option>'}${p.enum.map((v, i) => `<option value="${i}" ${JSON.stringify(v) === JSON.stringify(def) ? 'selected' : ''}>${esc(typeof v === 'string' ? v : JSON.stringify(v))}</option>`).join('')}</select>`;
|
|
1199
|
+
} else if (ty === 'boolean') {
|
|
1200
|
+
input = `<label class="switch"><input type="checkbox" id="${id}" data-name="${esc(name)}" data-type="boolean" ${def ? 'checked' : ''} ${aria}/><span>${esc(name)}</span></label>`;
|
|
1201
|
+
} else if (ty === 'integer' || ty === 'number') {
|
|
1202
|
+
input = `<input class="input" type="number" inputmode="decimal" id="${id}" data-name="${esc(name)}" data-type="${ty}" ${ty === 'integer' ? 'step="1"' : 'step="any"'} ${p.minimum != null ? `min="${p.minimum}"` : ''} ${p.maximum != null ? `max="${p.maximum}"` : ''} value="${def != null ? esc(def) : ''}" ${aria}/>`;
|
|
1203
|
+
} else if (ty === 'object' || ty === 'array') {
|
|
1204
|
+
input = `<textarea class="input" id="${id}" data-name="${esc(name)}" data-type="json" rows="3" spellcheck="false" placeholder="${ty === 'array' ? '[]' : '{}'}" ${aria}>${def != null ? esc(JSON.stringify(def, null, 2)) : ''}</textarea>`;
|
|
1205
|
+
} else {
|
|
1206
|
+
const long = (p.maxLength ?? 0) > 200 || /content|body|text|query|prompt|code/i.test(name);
|
|
1207
|
+
input = long
|
|
1208
|
+
? `<textarea class="input" id="${id}" data-name="${esc(name)}" data-type="string" rows="3" style="font-family:var(--font)" ${aria}>${def != null ? esc(def) : ''}</textarea>`
|
|
1209
|
+
: `<input class="input" type="${p.format === 'uri' ? 'url' : p.format === 'email' ? 'email' : 'text'}" id="${id}" data-name="${esc(name)}" data-type="string" value="${def != null ? esc(def) : ''}" autocomplete="off" ${aria}/>`;
|
|
1210
|
+
}
|
|
1211
|
+
return `<div class="field">${ty === 'boolean' ? '' : label}${input}${hint}</div>`;
|
|
1212
|
+
}
|
|
1213
|
+
function readForm(form, tool) {
|
|
1214
|
+
const schema = schemaOf(tool), props = schema.properties || {}, reqd = new Set(schema.required || []);
|
|
1215
|
+
const out = {}; let ok = true;
|
|
1216
|
+
$$('[data-name]', form).forEach((el) => {
|
|
1217
|
+
const name = el.dataset.name, p = props[name] || {}, ty = el.dataset.type;
|
|
1218
|
+
el.classList.remove('invalid'); el.removeAttribute('aria-invalid');
|
|
1219
|
+
let v;
|
|
1220
|
+
if (ty === 'boolean') { if (el.checked || reqd.has(name) || p.default !== undefined) v = el.checked; }
|
|
1221
|
+
else if (ty === 'enum') { if (el.value !== '') v = p.enum[Number(el.value)]; }
|
|
1222
|
+
else if (el.value.trim() === '') v = undefined;
|
|
1223
|
+
else if (ty === 'integer') v = parseInt(el.value, 10);
|
|
1224
|
+
else if (ty === 'number') v = Number(el.value);
|
|
1225
|
+
else if (ty === 'json') { try { v = JSON.parse(el.value); } catch { ok = false; el.classList.add('invalid'); el.setAttribute('aria-invalid', 'true'); } }
|
|
1226
|
+
else v = el.value;
|
|
1227
|
+
if (v === undefined && reqd.has(name)) { ok = false; el.classList.add('invalid'); el.setAttribute('aria-invalid', 'true'); }
|
|
1228
|
+
if (v !== undefined && !(typeof v === 'number' && isNaN(v))) out[name] = v;
|
|
1229
|
+
});
|
|
1230
|
+
return ok ? out : null;
|
|
1231
|
+
}
|
|
1232
|
+
function renderPlayground(host, ctx) {
|
|
1233
|
+
const tools = state.tools;
|
|
1234
|
+
if (!host.dataset.built) {
|
|
1235
|
+
host.dataset.built = '1';
|
|
1236
|
+
host.innerHTML = `<div class="card"><input class="input" type="search" data-role="filter" placeholder="${esc(t('searchTools'))}" aria-label="${esc(t('searchTools'))}"/><ul class="tool-list" data-role="list"></ul></div><div class="card" data-role="detail"></div>`;
|
|
1237
|
+
$('[data-role=filter]', host).addEventListener('input', (e) => { state.pg.filter = e.target.value; drawList(); });
|
|
407
1238
|
}
|
|
1239
|
+
const list = $('[data-role=list]', host), detail = $('[data-role=detail]', host);
|
|
1240
|
+
if (ctx === 'ob') { host.style.display = 'grid'; host.style.gridTemplateColumns = '1fr'; host.style.gap = '12px'; list.style.maxHeight = '170px'; }
|
|
1241
|
+
function drawList() {
|
|
1242
|
+
const q = state.pg.filter.toLowerCase();
|
|
1243
|
+
const items = tools.filter((x) => !q || x.name.toLowerCase().includes(q) || x.serverId.toLowerCase().includes(q) || (x.description || '').toLowerCase().includes(q));
|
|
1244
|
+
if (!tools.length) { list.innerHTML = `<li class="empty">${esc(t('noTools'))}</li>`; return; }
|
|
1245
|
+
list.innerHTML = items.slice(0, 300).map((x) => `<li><button type="button" data-k="${esc(key(x))}" aria-pressed="${state.pg.tool === key(x)}"><b>${esc(x.name)}</b><span>${esc(x.serverId)}${x.description ? ' · ' + esc(x.description) : ''}</span></button></li>`).join('');
|
|
1246
|
+
$$('button', list).forEach((b) => b.addEventListener('click', () => { state.pg.tool = b.dataset.k; $$('button', list).forEach((o) => o.setAttribute('aria-pressed', o === b)); drawDetail(true); }));
|
|
1247
|
+
}
|
|
1248
|
+
function drawDetail(animate) {
|
|
1249
|
+
const tool = tools.find((x) => key(x) === state.pg.tool);
|
|
1250
|
+
if (!tool) { detail.innerHTML = `<div class="empty">${ICON.playground.replace('<svg', '<svg width="34" height="34"')}<br>${esc(t('pickTool'))}</div>`; return; }
|
|
1251
|
+
const schema = schemaOf(tool), props = schema.properties || {}, reqd = new Set(schema.required || []);
|
|
1252
|
+
const names = Object.keys(props).sort((a, b) => (reqd.has(b) ? 1 : 0) - (reqd.has(a) ? 1 : 0));
|
|
1253
|
+
const fid = (n) => `${ctx}-f-${n.replace(/[^\w-]/g, '_')}`;
|
|
1254
|
+
detail.innerHTML = `<div class="${animate ? 'pop' : ''}">
|
|
1255
|
+
<div class="row" style="justify-content:space-between;margin-bottom:6px"><div><b class="mono" style="font-size:15px">${esc(tool.name)}</b> <span class="badge">${esc(tool.serverId)}</span></div>
|
|
1256
|
+
<label class="switch"><input type="checkbox" data-role="raw" ${state.pg.raw ? 'checked' : ''}/><span>${esc(t('rawJson'))}</span></label></div>
|
|
1257
|
+
${tool.description ? `<p style="color:var(--muted);margin:0 0 12px;white-space:pre-wrap">${esc(tool.description)}</p>` : ''}
|
|
1258
|
+
<form class="form" data-role="form" novalidate>
|
|
1259
|
+
<div data-role="fields" class="form" ${state.pg.raw ? 'hidden' : ''}>${names.length ? names.map((n) => fieldHtml(n, props[n] || {}, reqd.has(n), fid(n))).join('') : `<div class="hint" style="color:var(--faint)">${esc(t('noArgs'))}</div>`}</div>
|
|
1260
|
+
<div class="field" data-role="rawbox" ${state.pg.raw ? '' : 'hidden'}><label for="${ctx}-raw">${esc(t('args'))} (JSON)</label><textarea class="input" id="${ctx}-raw" rows="6" spellcheck="false">{}</textarea></div>
|
|
1261
|
+
<div class="row"><button class="btn primary" type="submit">${ICON.spin}${ICON.playground.replace('<svg', '<svg width="14" height="14"')}<span>${esc(t('call'))}</span></button><button class="btn ghost sm" type="button" data-role="curl">${ICON.copy}<span>${esc(t('asCurl'))}</span></button></div>
|
|
1262
|
+
</form>
|
|
1263
|
+
<div data-role="result"></div></div>`;
|
|
1264
|
+
const form = $('[data-role=form]', detail), raw = $(`#${ctx}-raw`, detail), fields = $('[data-role=fields]', detail), rawbox = $('[data-role=rawbox]', detail);
|
|
1265
|
+
const currentArgs = () => {
|
|
1266
|
+
if (state.pg.raw) { try { raw.classList.remove('invalid'); const v = JSON.parse(raw.value || '{}'); if (typeof v !== 'object' || Array.isArray(v) || v === null) throw 0; return v; } catch { raw.classList.add('invalid'); return null; } }
|
|
1267
|
+
return readForm(form, tool);
|
|
1268
|
+
};
|
|
1269
|
+
$('[data-role=raw]', detail).addEventListener('change', (e) => {
|
|
1270
|
+
const args = state.pg.raw ? null : readForm(form, tool);
|
|
1271
|
+
state.pg.raw = e.target.checked;
|
|
1272
|
+
fields.hidden = state.pg.raw; rawbox.hidden = !state.pg.raw;
|
|
1273
|
+
if (state.pg.raw) raw.value = JSON.stringify(args || {}, null, 2);
|
|
1274
|
+
});
|
|
1275
|
+
$('[data-role=curl]', detail).addEventListener('click', (e) => {
|
|
1276
|
+
const args = currentArgs() || {};
|
|
1277
|
+
const body = JSON.stringify({ tool: tool.name, server: tool.serverId, arguments: args });
|
|
1278
|
+
copyText(`curl -sS -X POST ${ORIGIN}/api/v1/tools/call \\\n -H 'Content-Type: application/json' \\\n -H 'Authorization: Bearer ${getKey() ? '<YOUR_API_KEY>' : ''}' \\\n -d '${body.replace(/'/g, "'\\''")}'`.replace(/ \\\n -H 'Authorization: Bearer '/, ''), e.currentTarget);
|
|
1279
|
+
});
|
|
1280
|
+
form.addEventListener('submit', async (e) => {
|
|
1281
|
+
e.preventDefault();
|
|
1282
|
+
const args = currentArgs();
|
|
1283
|
+
const res = $('[data-role=result]', detail);
|
|
1284
|
+
if (!args) { res.innerHTML = `<div class="result bad pop"><div class="row"><span class="badge bad">${esc(t('invalidJson'))} / ${esc(t('required'))}</span></div></div>`; $('.invalid', detail)?.focus(); return; }
|
|
1285
|
+
const btn = $('button[type=submit]', form); btn.classList.add('loading'); btn.disabled = true;
|
|
1286
|
+
const t0 = performance.now();
|
|
1287
|
+
let status = 0, body;
|
|
1288
|
+
try {
|
|
1289
|
+
const r = await fetch('/api/v1/tools/call', { method: 'POST', headers: headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ tool: tool.name, server: tool.serverId, arguments: args }) });
|
|
1290
|
+
status = r.status; body = await r.json().catch(() => ({}));
|
|
1291
|
+
} catch (err) { body = { error: String(err) }; }
|
|
1292
|
+
btn.classList.remove('loading'); btn.disabled = false;
|
|
1293
|
+
const ok = status >= 200 && status < 300 && !body?.result?.isError;
|
|
1294
|
+
const ms = body?.durationMs ?? Math.round(performance.now() - t0);
|
|
1295
|
+
const shown = ok ? body.result : body;
|
|
1296
|
+
const text = shown?.content?.every?.((c) => c.type === 'text') ? shown.content.map((c) => c.text).join('\n') : JSON.stringify(shown, null, 2);
|
|
1297
|
+
res.innerHTML = `<div class="result ${ok ? 'ok' : 'bad'} pop" role="status"><div class="row"><span class="badge ${ok ? 'ok' : 'bad'}">${esc(ok ? t('callOk', { ms }) : t('callBad', { s: status || '—' }))}</span><span class="spacer"></span><button class="btn ghost sm" type="button" data-role="copyres">${ICON.copy}<span>${esc(t('copy'))}</span></button></div><pre class="mono">${esc(text)}</pre></div>`;
|
|
1298
|
+
$('[data-role=copyres]', res).addEventListener('click', (ev) => copyText(text, ev.currentTarget));
|
|
1299
|
+
if (ctx === 'ob') obState.called = ok;
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
const refresh = () => { drawList(); drawDetail(false); };
|
|
1303
|
+
if (!tools.length) {
|
|
1304
|
+
list.innerHTML = '<li><div class="sk sk-line"></div><div class="sk sk-line" style="width:70%"></div><div class="sk sk-line" style="width:85%"></div></li>';
|
|
1305
|
+
detail.innerHTML = '<div class="sk sk-line" style="width:40%"></div><div class="sk sk-line"></div><div class="sk sk-line" style="height:38px"></div>';
|
|
1306
|
+
loadTools().then(() => {
|
|
1307
|
+
if (!state.pg.tool && state.tools.length) {
|
|
1308
|
+
// Prefer a tool without required args for a guaranteed first success.
|
|
1309
|
+
const easy = state.tools.find((x) => !(schemaOf(x).required || []).length) || state.tools[0];
|
|
1310
|
+
state.pg.tool = key(easy);
|
|
1311
|
+
}
|
|
1312
|
+
refresh();
|
|
1313
|
+
}).catch((e) => { list.innerHTML = `<li class="empty">${esc(e.status === 401 ? t('authRequired') : t('loadErr', { e: e.message }))}</li>`; detail.innerHTML = ''; });
|
|
1314
|
+
} else {
|
|
1315
|
+
if (!state.pg.tool) { const easy = tools.find((x) => !(schemaOf(x).required || []).length) || tools[0]; state.pg.tool = key(easy); }
|
|
1316
|
+
refresh();
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
408
1319
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
1320
|
+
// ─── Snippets ────────────────────────────────────────────────────────────────
|
|
1321
|
+
function mcpUrl() { return ORIGIN + (state.root?.mcp && state.root.mcp !== 'disabled' ? state.root.mcp : '/mcp'); }
|
|
1322
|
+
function snippets() {
|
|
1323
|
+
const url = mcpUrl(), real = state.includeKey && getKey();
|
|
1324
|
+
const k = real ? getKey() : '<YOUR_API_KEY>';
|
|
1325
|
+
const auth = `Bearer ${k}`;
|
|
1326
|
+
return {
|
|
1327
|
+
claude: { label: 'Claude Desktop', note: t('snipClaudeNote'), lang: 'json', code: JSON.stringify({ mcpServers: { 'mcp-gateway': { command: 'npx', args: ['-y', 'mcp-remote', url, '--header', 'Authorization:${AUTH_HEADER}'], env: { AUTH_HEADER: auth } } } }, null, 2) },
|
|
1328
|
+
cursor: { label: 'Cursor', note: t('snipCursorNote'), lang: 'json', code: JSON.stringify({ mcpServers: { 'mcp-gateway': { url, headers: { Authorization: auth } } } }, null, 2) },
|
|
1329
|
+
code: { label: 'Claude Code', note: t('snipCodeNote'), lang: 'sh', code: `claude mcp add --transport http mcp-gateway ${url} \\\n --header "Authorization: ${auth}"` },
|
|
1330
|
+
js: { label: 'JS / TS', note: t('snipJsNote'), lang: 'ts', code: `import { GatewayClient, connectMcp } from '@winstonsayno/mcp-gateway-client';\n\nconst gw = new GatewayClient({ baseUrl: '${ORIGIN}', apiKey: '${k}' });\n\n// REST: auto-routed tool call\nconst tools = await gw.listTools();\nconst r = await gw.callTool('${firstToolName()}', {});\n\n// Or speak MCP over ${url.replace(ORIGIN, '')}\nconst mcp = await connectMcp(gw);\nconsole.log(await mcp.listTools());\nawait mcp.close();` },
|
|
1331
|
+
kotlin: { label: 'Kotlin', note: t('snipKotlinNote'), lang: 'kt', code: `val gw = GatewayClient("${ORIGIN}", apiKey = "${k}")\n\nval tools = withContext(Dispatchers.IO) { gw.listTools() }\n\n// MCP session over ${url.replace(ORIGIN, '')}\nwithContext(Dispatchers.IO) {\n gw.mcp().use { session ->\n val result = session.callTool("${firstToolName()}", buildJsonObject { })\n println(result)\n }\n}` },
|
|
1332
|
+
curl: { label: 'curl', note: t('snipCurlNote'), lang: 'sh', code: `# 1) initialize — note the Mcp-Session-Id response header\ncurl -i -X POST ${url} \\\n -H 'Authorization: ${auth}' \\\n -H 'Content-Type: application/json' \\\n -H 'Accept: application/json, text/event-stream' \\\n -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'\n\n# 2) list tools with that session\ncurl -X POST ${url} \\\n -H 'Authorization: ${auth}' \\\n -H 'Content-Type: application/json' \\\n -H 'Accept: application/json, text/event-stream' \\\n -H 'Mcp-Session-Id: <SESSION_ID>' \\\n -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'` },
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
function firstToolName() { return state.tools[0]?.name || 'echo'; }
|
|
1336
|
+
function highlight(code, lang) {
|
|
1337
|
+
let h = esc(code);
|
|
1338
|
+
if (lang === 'json') h = h.replace(/("(?:(?!").)*?")(\s*:)?/g, (m, s, c) => (c ? `<span class="tk-k">${s}</span>${c}` : `<span class="tk-s">${s}</span>`));
|
|
1339
|
+
else {
|
|
1340
|
+
h = h.replace(/(^|\n)(#[^\n]*)/g, '$1<span class="tk-c">$2</span>').replace(/(\/\/[^\n]*)/g, (m) => (m.startsWith('//') && !/^\/\/[a-z0-9.-]+[:/]/i.test(m) ? `<span class="tk-c">${m}</span>` : m));
|
|
1341
|
+
h = h.replace(/('[^\n]*?'|"[^\n]*?")/g, '<span class="tk-s">$1</span>');
|
|
1342
|
+
h = h.replace(/\b(import|from|const|await|val|new|withContext|use|curl)\b/g, '<span class="tk-k">$1</span>');
|
|
425
1343
|
}
|
|
1344
|
+
return h;
|
|
1345
|
+
}
|
|
1346
|
+
function renderSnippets(host, ctx) {
|
|
1347
|
+
const S = snippets();
|
|
1348
|
+
if (!S[state.snip]) state.snip = 'cursor';
|
|
1349
|
+
const tabs = Object.entries(S).map(([id, s]) => `<button type="button" role="tab" aria-selected="${id === state.snip}" data-snip="${id}">${esc(s.label)}</button>`).join('');
|
|
1350
|
+
const cur = S[state.snip];
|
|
1351
|
+
host.innerHTML = `
|
|
1352
|
+
<div class="row" style="justify-content:space-between;margin-bottom:12px">
|
|
1353
|
+
<div class="field" style="flex:1;min-width:220px"><label>${esc(t('mcpUrl'))}</label><div class="code"><pre class="mono" style="padding:9px 48px 9px 12px">${esc(mcpUrl())}</pre><button class="icon-btn copy" type="button" data-copy-url aria-label="${esc(t('copy'))}" style="top:3px;right:4px;width:30px;height:30px">${ICON.copy}</button></div></div>
|
|
1354
|
+
${getKey() ? `<label class="switch"><input type="checkbox" data-include ${state.includeKey ? 'checked' : ''}/><span>${esc(t('includeKey'))}</span></label>` : ''}
|
|
1355
|
+
</div>
|
|
1356
|
+
<div class="snip-tabs" role="tablist">${tabs}</div>
|
|
1357
|
+
<div class="code pop" role="tabpanel"><pre><code>${highlight(cur.code, cur.lang)}</code></pre><button class="icon-btn copy" type="button" data-copy aria-label="${esc(t('copy'))}">${ICON.copy}</button></div>
|
|
1358
|
+
<p class="snip-note">${esc(cur.note)}</p>`;
|
|
1359
|
+
$$('[data-snip]', host).forEach((b) => b.addEventListener('click', () => { state.snip = b.dataset.snip; renderSnippets(host, ctx); $(`[data-snip="${state.snip}"]`, host).focus(); }));
|
|
1360
|
+
$('[role=tablist]', host).addEventListener('keydown', (e) => {
|
|
1361
|
+
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
|
|
1362
|
+
const ids = Object.keys(S), i = ids.indexOf(state.snip) + (e.key === 'ArrowRight' ? 1 : -1);
|
|
1363
|
+
state.snip = ids[(i + ids.length) % ids.length]; renderSnippets(host, ctx); $(`[data-snip="${state.snip}"]`, host).focus();
|
|
1364
|
+
});
|
|
1365
|
+
$('[data-copy]', host).addEventListener('click', (e) => copyText(snippets()[state.snip].code, e.currentTarget));
|
|
1366
|
+
$('[data-copy-url]', host).addEventListener('click', (e) => copyText(mcpUrl(), e.currentTarget));
|
|
1367
|
+
$('[data-include]', host)?.addEventListener('change', (e) => { state.includeKey = e.target.checked; renderSnippets(host, ctx); });
|
|
1368
|
+
}
|
|
426
1369
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1370
|
+
// ─── History ─────────────────────────────────────────────────────────────────
|
|
1371
|
+
function fillServerFilter() {
|
|
1372
|
+
const sel = $('#f-server'), cur = sel.value;
|
|
1373
|
+
sel.innerHTML = `<option value="">${esc(t('any'))}</option>` + state.servers.map((s) => `<option value="${esc(s.id)}">${esc(s.id)}</option>`).join('');
|
|
1374
|
+
sel.value = cur;
|
|
1375
|
+
}
|
|
1376
|
+
async function loadHistory() {
|
|
1377
|
+
const h = state.hist, body = $('#hiBody'), cards = $('#hiCards');
|
|
1378
|
+
if (!state.servers.length) loadServers();
|
|
1379
|
+
body.innerHTML = Array.from({ length: 6 }, () => `<tr>${'<td><div class="sk sk-line"></div></td>'.repeat(7)}</tr>`).join('');
|
|
1380
|
+
cards.innerHTML = Array.from({ length: 4 }, () => '<div class="hc"><div class="sk sk-line" style="width:60%"></div><div class="sk sk-line" style="width:40%"></div></div>').join('');
|
|
1381
|
+
const q = new URLSearchParams({ limit: '25' });
|
|
1382
|
+
for (const [k, v] of Object.entries(h.filters)) if (v) q.set(k, v);
|
|
1383
|
+
const cursor = h.cursors[h.page];
|
|
1384
|
+
if (cursor) q.set('cursor', cursor);
|
|
1385
|
+
try {
|
|
1386
|
+
const r = await api('/requests?' + q);
|
|
1387
|
+
h.next = r.nextCursor || null;
|
|
1388
|
+
$('#hiSource').textContent = `${t('hiSub')} · ${r.source === 'audit' ? t('srcAudit') : t('srcMemory')}`;
|
|
1389
|
+
if (!r.requests.length) {
|
|
1390
|
+
body.innerHTML = `<tr><td colspan="7"><div class="empty">${ICON.empty}${esc(t('noData'))}</div></td></tr>`;
|
|
1391
|
+
cards.innerHTML = `<div class="empty">${ICON.empty}${esc(t('noData'))}</div>`;
|
|
433
1392
|
} else {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
const q = requestQuery();
|
|
438
|
-
if (!reset && reqCursor) q.set('cursor', reqCursor);
|
|
439
|
-
const data = await fetchJSON('/api/v1/requests?' + q.toString());
|
|
440
|
-
const reqs = data.requests ?? [];
|
|
441
|
-
document.getElementById('req-source').textContent =
|
|
442
|
-
data.source === 'audit' ? 'source: persistent audit log' : data.source === 'memory' ? 'source: in-memory (since start)' : '';
|
|
443
|
-
reqCursor = data.nextCursor;
|
|
444
|
-
more.hidden = !reqCursor;
|
|
445
|
-
if (reset && !reqs.length) {
|
|
446
|
-
tbody.innerHTML = '<tr><td colspan="7" class="empty">No requests</td></tr>';
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
const html = reqs.map(requestRow).join('');
|
|
450
|
-
if (reset) tbody.innerHTML = html;
|
|
451
|
-
else tbody.insertAdjacentHTML('beforeend', html);
|
|
452
|
-
} catch (e) {
|
|
453
|
-
more.hidden = true;
|
|
454
|
-
tbody.innerHTML = `<tr><td colspan="7" class="empty">${e instanceof AuthError ? 'API key required' : 'Failed to load requests'}</td></tr>`;
|
|
1393
|
+
body.innerHTML = r.requests.map((x) => `<tr title="${esc(x.errorMessage || '')}"><td>${esc(fmtDateTime(x.timestamp))}</td><td class="mono">${esc(x.toolName)}</td><td>${esc(x.serverId)}</td><td class="mono">${esc(x.clientId || '—')}</td><td>${esc(x.via || 'rest')}</td><td>${fmtMs(x.durationMs)}</td><td><span class="badge ${x.success ? 'ok' : 'bad'}">${esc(x.success ? t('ok') : t('failed'))}</span></td></tr>`).join('');
|
|
1394
|
+
cards.innerHTML = r.requests.map((x) => `<div class="hc"><div class="top"><span class="dot ${x.success ? 'ok' : 'bad'}" style="animation:none"></span><b>${esc(x.toolName)}</b><span class="badge ${x.success ? 'ok' : 'bad'}">${fmtMs(x.durationMs)}</span></div><div class="sub"><span>${esc(x.serverId)}</span><span>${esc(x.clientId || '—')}</span><span>${esc(x.via || 'rest')}</span><span>${esc(fmtDateTime(x.timestamp))}</span></div>${!x.success && x.errorMessage ? `<div style="color:var(--bad)">${esc(x.errorMessage)}</div>` : ''}</div>`).join('');
|
|
455
1395
|
}
|
|
1396
|
+
$('#hiInfo').textContent = t('page', { n: h.page + 1 });
|
|
1397
|
+
$('#hiPrev').disabled = h.page === 0;
|
|
1398
|
+
$('#hiNext').disabled = !h.next;
|
|
1399
|
+
} catch (e) {
|
|
1400
|
+
body.innerHTML = `<tr><td colspan="7"><div class="empty">${esc(e.status === 401 ? t('authRequired') : t('loadErr', { e: e.message }))}</div></td></tr>`;
|
|
1401
|
+
cards.innerHTML = '';
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
$('#hiFilters').addEventListener('submit', (e) => {
|
|
1405
|
+
e.preventDefault();
|
|
1406
|
+
state.hist.filters = Object.fromEntries(new FormData(e.target));
|
|
1407
|
+
state.hist.page = 0; state.hist.cursors = [undefined];
|
|
1408
|
+
loadHistory();
|
|
1409
|
+
});
|
|
1410
|
+
$('#hiFilters').addEventListener('reset', () => setTimeout(() => { state.hist.filters = {}; state.hist.page = 0; state.hist.cursors = [undefined]; loadHistory(); }));
|
|
1411
|
+
$('#hiNext').addEventListener('click', () => { const h = state.hist; if (!h.next) return; h.cursors[h.page + 1] = h.next; h.page++; loadHistory(); });
|
|
1412
|
+
$('#hiPrev').addEventListener('click', () => { const h = state.hist; if (h.page === 0) return; h.page--; loadHistory(); });
|
|
1413
|
+
|
|
1414
|
+
// ─── Window selector ─────────────────────────────────────────────────────────
|
|
1415
|
+
$$('#windowSeg button').forEach((b) => b.addEventListener('click', () => {
|
|
1416
|
+
$$('#windowSeg button').forEach((o) => o.setAttribute('aria-pressed', o === b));
|
|
1417
|
+
state.windowMs = Number(b.dataset.w);
|
|
1418
|
+
loadStats().catch(() => {});
|
|
1419
|
+
startLive();
|
|
1420
|
+
}));
|
|
1421
|
+
|
|
1422
|
+
// ─── API key popover ─────────────────────────────────────────────────────────
|
|
1423
|
+
const keyPop = $('#keyPop');
|
|
1424
|
+
function toggleKeyPop(open = keyPop.hidden) {
|
|
1425
|
+
keyPop.hidden = !open;
|
|
1426
|
+
$('#keyBtn').setAttribute('aria-expanded', open);
|
|
1427
|
+
if (open) { $('#keyInput').value = getKey(); $('#keyRemember').checked = !!store.get(LS.key); $('#keyStatus').textContent = ''; $('#keyInput').focus(); }
|
|
1428
|
+
}
|
|
1429
|
+
$('#keyBtn').addEventListener('click', () => toggleKeyPop());
|
|
1430
|
+
document.addEventListener('click', (e) => { if (!keyPop.hidden && !keyPop.contains(e.target) && !$('#keyBtn').contains(e.target)) toggleKeyPop(false); });
|
|
1431
|
+
$('#keyInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#keySave').click(); });
|
|
1432
|
+
$('#keySave').addEventListener('click', async () => {
|
|
1433
|
+
const v = $('#keyInput').value.trim();
|
|
1434
|
+
setKey(v, $('#keyRemember').checked);
|
|
1435
|
+
const r = await testConnection($('#keyStatus'));
|
|
1436
|
+
if (r) { setTimeout(() => toggleKeyPop(false), 600); reloadAll(); }
|
|
1437
|
+
});
|
|
1438
|
+
$('#keyClear').addEventListener('click', () => { setKey(''); $('#keyInput').value = ''; state.auth = 'unknown'; reloadAll(); });
|
|
1439
|
+
|
|
1440
|
+
/** Probe a protected endpoint; renders the outcome into `line`. */
|
|
1441
|
+
async function testConnection(line) {
|
|
1442
|
+
line.className = 'status-line info'; line.innerHTML = `${ICON.spin.replace('class="spin"', 'class="spin" width="14" height="14" style="animation:spin .8s linear infinite"')} ${esc(t('checking'))}`;
|
|
1443
|
+
try {
|
|
1444
|
+
const r = await fetch('/api/v1/requests?limit=1', { headers: headers(), cache: 'no-store' });
|
|
1445
|
+
if (r.status === 401) { state.auth = 'needed'; updateLive(); line.className = 'status-line bad'; line.textContent = '✕ ' + t('connBad'); return false; }
|
|
1446
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
1447
|
+
state.auth = 'ok'; updateLive();
|
|
1448
|
+
line.className = 'status-line ok';
|
|
1449
|
+
line.textContent = '✓ ' + (getKey() ? t('connOk') : t('connOkNoAuth'));
|
|
1450
|
+
return true;
|
|
1451
|
+
} catch (e) {
|
|
1452
|
+
line.className = 'status-line bad'; line.textContent = '✕ ' + t('connErr', { e: e.message }); return false;
|
|
456
1453
|
}
|
|
1454
|
+
}
|
|
457
1455
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
1456
|
+
// ─── Onboarding ──────────────────────────────────────────────────────────────
|
|
1457
|
+
const OB_STEPS = 5;
|
|
1458
|
+
const obState = { step: 0, called: false, lastFocus: null };
|
|
1459
|
+
function openGuide() {
|
|
1460
|
+
obState.lastFocus = document.activeElement;
|
|
1461
|
+
const ob = $('#ob'); ob.hidden = false;
|
|
1462
|
+
requestAnimationFrame(() => ob.classList.add('open'));
|
|
1463
|
+
$('#obKey').value = getKey(); $('#obRemember').checked = !!store.get(LS.key);
|
|
1464
|
+
showStep(0);
|
|
1465
|
+
document.body.style.overflow = 'hidden';
|
|
1466
|
+
}
|
|
1467
|
+
function closeGuide() {
|
|
1468
|
+
const ob = $('#ob');
|
|
1469
|
+
ob.classList.remove('open');
|
|
1470
|
+
store.set(LS.onboarded, '1');
|
|
1471
|
+
document.body.style.overflow = '';
|
|
1472
|
+
setTimeout(() => { ob.hidden = true; obState.lastFocus?.focus?.(); }, reduced() ? 0 : 300);
|
|
1473
|
+
}
|
|
1474
|
+
function showStep(i) {
|
|
1475
|
+
const back = i < obState.step;
|
|
1476
|
+
obState.step = i;
|
|
1477
|
+
$$('.ob-step').forEach((s) => { s.classList.toggle('active', Number(s.dataset.step) === i); s.classList.toggle('back', back); });
|
|
1478
|
+
$('#obSteps').innerHTML = Array.from({ length: OB_STEPS }, (_, k) => `<button type="button" class="${k <= i ? 'done' : ''}" aria-label="${esc(t('stepOf', { a: k + 1, b: OB_STEPS }))}" ${k === i ? 'aria-current="step"' : ''} data-go="${k}"></button>`).join('');
|
|
1479
|
+
$$('#obSteps button').forEach((b) => b.addEventListener('click', () => showStep(Number(b.dataset.go))));
|
|
1480
|
+
$('#obCount').textContent = t('stepOf', { a: i + 1, b: OB_STEPS });
|
|
1481
|
+
$('#obBack').hidden = i === 0;
|
|
1482
|
+
$('#obSkip').hidden = i === OB_STEPS - 1;
|
|
1483
|
+
$('#obNext').textContent = i === OB_STEPS - 1 ? t('finish') : t('nextStep');
|
|
1484
|
+
$('#obBody').scrollTop = 0;
|
|
1485
|
+
if (i === 1) obServers();
|
|
1486
|
+
if (i === 2) { const host = $('#obPg'); renderPlayground(host, 'ob'); }
|
|
1487
|
+
if (i === 3) renderSnippets($('#obSnips'), 'ob');
|
|
1488
|
+
const focusable = $('.ob-step.active input, .ob-step.active button', $('#ob'));
|
|
1489
|
+
(i === 0 ? $('#obKey') : focusable || $('#obNext')).focus({ preventScroll: true });
|
|
1490
|
+
}
|
|
1491
|
+
async function obServers() {
|
|
1492
|
+
const el = $('#obServers');
|
|
1493
|
+
el.innerHTML = Array.from({ length: 3 }, () => '<div class="item"><div class="sk sk-line" style="width:100%"></div></div>').join('');
|
|
1494
|
+
try {
|
|
1495
|
+
const r = await api('/servers');
|
|
1496
|
+
state.servers = r.servers;
|
|
1497
|
+
el.innerHTML = r.servers.length
|
|
1498
|
+
? r.servers.map((s, i) => `<div class="item" style="animation-delay:${i * 50}ms"><span class="dot ${statusCls(s.health?.status)}"></span><b>${esc(s.name || s.id)}</b><span class="badge">${esc(s.transport)}</span><span class="meta">${esc(t('kTools', { n: s.toolCount ?? 0 }))} · ${esc(t('status_' + (s.health?.status || 'unknown')))}</span></div>`).join('')
|
|
1499
|
+
: `<div class="empty">${ICON.empty}${esc(t('noServers'))}</div>`;
|
|
1500
|
+
} catch (e) {
|
|
1501
|
+
el.innerHTML = `<div class="empty">${esc(e.status === 401 ? t('authRequired') : t('loadErr', { e: e.message }))}</div>`;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
$('#obTest').addEventListener('click', async () => {
|
|
1505
|
+
const b = $('#obTest'); b.classList.add('loading');
|
|
1506
|
+
setKey($('#obKey').value.trim(), $('#obRemember').checked);
|
|
1507
|
+
const ok = await testConnection($('#obKeyStatus'));
|
|
1508
|
+
b.classList.remove('loading');
|
|
1509
|
+
if (ok) { state.tools = []; delete $('#obPg').dataset.built; reloadAll(); }
|
|
1510
|
+
});
|
|
1511
|
+
$('#obKey').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#obTest').click(); });
|
|
1512
|
+
$('#obNext').addEventListener('click', async () => {
|
|
1513
|
+
if (obState.step === 0 && state.auth !== 'ok') {
|
|
1514
|
+
setKey($('#obKey').value.trim(), $('#obRemember').checked);
|
|
1515
|
+
const ok = await testConnection($('#obKeyStatus'));
|
|
1516
|
+
if (!ok) return;
|
|
1517
|
+
state.tools = []; delete $('#obPg').dataset.built; reloadAll();
|
|
465
1518
|
}
|
|
1519
|
+
if (obState.step < OB_STEPS - 1) showStep(obState.step + 1);
|
|
1520
|
+
else { closeGuide(); go('overview'); }
|
|
1521
|
+
});
|
|
1522
|
+
$('#obBack').addEventListener('click', () => showStep(Math.max(0, obState.step - 1)));
|
|
1523
|
+
$('#obSkip').addEventListener('click', closeGuide);
|
|
1524
|
+
$('#obClose').addEventListener('click', closeGuide);
|
|
1525
|
+
$('#guideBtn').addEventListener('click', openGuide);
|
|
1526
|
+
$('#ob').addEventListener('click', (e) => { if (e.target.id === 'ob') closeGuide(); });
|
|
1527
|
+
document.addEventListener('keydown', (e) => {
|
|
1528
|
+
const ob = $('#ob');
|
|
1529
|
+
if (e.key === 'Escape') { if (!ob.hidden) closeGuide(); else if (!keyPop.hidden) toggleKeyPop(false); }
|
|
1530
|
+
if (e.key === 'Tab' && !ob.hidden) {
|
|
1531
|
+
// Focus trap inside the dialog.
|
|
1532
|
+
const f = $$('button, input, select, textarea, [tabindex]:not([tabindex="-1"])', $('.ob', ob)).filter((x) => !x.disabled && x.offsetParent !== null);
|
|
1533
|
+
if (!f.length) return;
|
|
1534
|
+
const first = f[0], last = f[f.length - 1];
|
|
1535
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
1536
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
// ─── Language ────────────────────────────────────────────────────────────────
|
|
1541
|
+
$('#langBtn').addEventListener('click', () => transition(() => {
|
|
1542
|
+
lang = lang === 'zh' ? 'en' : 'zh';
|
|
1543
|
+
store.set(LS.lang, lang);
|
|
1544
|
+
applyI18n(); buildNav(); renderKpiShell(); renderOverview(); renderStream(); renderHealthMini(); updateLive();
|
|
1545
|
+
if (state.servers.length) renderServers();
|
|
1546
|
+
delete $('#pgMain').dataset.built; delete $('#obPg').dataset.built;
|
|
1547
|
+
if (state.view === 'playground') renderPlayground($('#pgMain'), 'main');
|
|
1548
|
+
if (state.view === 'connect') renderSnippets($('#connectCard'), 'main');
|
|
1549
|
+
if (state.view === 'history') loadHistory();
|
|
1550
|
+
if (!$('#ob').hidden) showStep(obState.step);
|
|
1551
|
+
}));
|
|
466
1552
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
1553
|
+
// ─── Boot ────────────────────────────────────────────────────────────────────
|
|
1554
|
+
async function reloadAll() {
|
|
1555
|
+
await loadRoot();
|
|
1556
|
+
await Promise.allSettled([loadStats(), loadServers(), loadTools()]);
|
|
1557
|
+
try {
|
|
1558
|
+
const recent = await api('/requests?limit=30');
|
|
1559
|
+
state.stream = recent.requests.slice(0, 50);
|
|
1560
|
+
renderStream();
|
|
1561
|
+
} catch { renderStream(); }
|
|
1562
|
+
if (state.auth !== 'needed') state.auth = 'ok';
|
|
1563
|
+
updateLive();
|
|
1564
|
+
startLive();
|
|
1565
|
+
if (state.view === 'playground') { delete $('#pgMain').dataset.built; renderPlayground($('#pgMain'), 'main'); }
|
|
1566
|
+
if (state.view === 'history') loadHistory();
|
|
1567
|
+
}
|
|
1568
|
+
applyI18n();
|
|
1569
|
+
buildNav();
|
|
1570
|
+
renderKpiShell();
|
|
1571
|
+
go(location.hash.slice(1) || 'overview', false);
|
|
1572
|
+
renderStream();
|
|
1573
|
+
reloadAll().then(() => {
|
|
1574
|
+
if (!store.get(LS.onboarded) || state.auth === 'needed' || new URLSearchParams(location.search).has('guide')) openGuide();
|
|
1575
|
+
});
|
|
1576
|
+
// Refresh the slower-moving pieces in the background.
|
|
1577
|
+
setInterval(() => { if (!document.hidden) { loadServers(); if (state.live === 'live') loadStats().catch(() => {}); } }, 10000);
|
|
1578
|
+
document.addEventListener('visibilitychange', () => { if (!document.hidden && state.live !== 'live') startLive(); });
|
|
1579
|
+
})();
|
|
470
1580
|
</script>
|
|
471
1581
|
</body>
|
|
472
1582
|
</html>
|