@muyichengshayu/promptx 0.1.11 → 0.1.12
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 +6 -0
- package/apps/server/src/relayServer.js +344 -0
- package/apps/server/src/relayUsageStore.js +242 -0
- package/docs/relay-quickstart.md +35 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.12
|
|
4
|
+
|
|
5
|
+
- Relay 新增按天聚合的租户使用统计,自动记录设备连接与真实转发请求,方便查看“今天有哪些同事实际用了 Relay”。
|
|
6
|
+
- 新增 Relay 管理统计页 `/relay/admin/usage` 与对应接口,可直接查看今日活跃租户、连接次数、请求次数、最近设备与最近活跃时间。
|
|
7
|
+
- 统计能力默认落在云端 Relay 侧,不依赖本地 PromptX client 升级;同时补充文档说明与服务端测试覆盖。
|
|
8
|
+
|
|
3
9
|
## 0.1.11
|
|
4
10
|
|
|
5
11
|
- 加固 Relay 自动重连恢复链路:固定间隔重连升级为指数退避,并在连接成功后重置重连计数,减少 Relay 重启或网络抖动时的无效重试。
|
|
@@ -15,10 +15,12 @@ import {
|
|
|
15
15
|
parseCookieHeader,
|
|
16
16
|
sanitizeProxyHeaders,
|
|
17
17
|
} from './relayProtocol.js'
|
|
18
|
+
import { createRelayUsageStore } from './relayUsageStore.js'
|
|
18
19
|
|
|
19
20
|
const DEFAULT_RELAY_PORT = 3030
|
|
20
21
|
const DEFAULT_RELAY_HOST = '0.0.0.0'
|
|
21
22
|
const DEFAULT_COOKIE_NAME = 'promptx_relay_access'
|
|
23
|
+
const DEFAULT_ADMIN_COOKIE_NAME = 'promptx_relay_admin'
|
|
22
24
|
const DEVICE_AUTH_TIMEOUT_MS = 5_000
|
|
23
25
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 25_000
|
|
24
26
|
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 55_000
|
|
@@ -128,6 +130,9 @@ function readRelayServerConfig() {
|
|
|
128
130
|
host: String(process.env.PROMPTX_RELAY_HOST || process.env.HOST || DEFAULT_RELAY_HOST).trim() || DEFAULT_RELAY_HOST,
|
|
129
131
|
port: Math.max(1, Number(process.env.PROMPTX_RELAY_PORT || process.env.PORT) || DEFAULT_RELAY_PORT),
|
|
130
132
|
accessCookieName: String(process.env.PROMPTX_RELAY_ACCESS_COOKIE || DEFAULT_COOKIE_NAME).trim() || DEFAULT_COOKIE_NAME,
|
|
133
|
+
adminCookieName: String(process.env.PROMPTX_RELAY_ADMIN_COOKIE || DEFAULT_ADMIN_COOKIE_NAME).trim() || DEFAULT_ADMIN_COOKIE_NAME,
|
|
134
|
+
adminToken: String(process.env.PROMPTX_RELAY_ADMIN_TOKEN || '').trim(),
|
|
135
|
+
usageFile: String(process.env.PROMPTX_RELAY_USAGE_FILE || '').trim(),
|
|
131
136
|
tenants: tenantConfig.tenants,
|
|
132
137
|
tenantSource: tenantConfig.source,
|
|
133
138
|
}
|
|
@@ -219,6 +224,245 @@ function buildUnknownTenantPage(host = '') {
|
|
|
219
224
|
</html>`
|
|
220
225
|
}
|
|
221
226
|
|
|
227
|
+
function buildAdminLoginPage({ errorMessage = '', redirectPath = '/relay/admin/usage' } = {}) {
|
|
228
|
+
const escapedError = String(errorMessage || '').replace(/[<>&"]/g, '')
|
|
229
|
+
const escapedRedirect = String(redirectPath || '/relay/admin/usage').replace(/"/g, '"')
|
|
230
|
+
|
|
231
|
+
return `<!doctype html>
|
|
232
|
+
<html lang="zh-CN">
|
|
233
|
+
<head>
|
|
234
|
+
<meta charset="utf-8" />
|
|
235
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
236
|
+
<title>PromptX Relay 管理登录</title>
|
|
237
|
+
<style>
|
|
238
|
+
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5f5f4; color: #1c1917; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
239
|
+
.card { width: min(92vw, 440px); border: 1px solid #d6d3d1; background: #fafaf9; box-shadow: 8px 8px 0 rgba(28,25,23,.06); padding: 24px; }
|
|
240
|
+
h1 { margin: 0 0 10px; font-size: 22px; }
|
|
241
|
+
p { margin: 0 0 16px; line-height: 1.6; color: #57534e; }
|
|
242
|
+
label { display: block; margin-bottom: 8px; font-size: 13px; color: #44403c; }
|
|
243
|
+
input { box-sizing: border-box; width: 100%; border: 1px solid #a8a29e; padding: 10px 12px; background: white; }
|
|
244
|
+
button { margin-top: 14px; width: 100%; border: 1px solid #166534; background: #16a34a; color: white; padding: 10px 12px; cursor: pointer; }
|
|
245
|
+
.error { margin-bottom: 12px; color: #b91c1c; font-size: 13px; }
|
|
246
|
+
.hint { margin-top: 14px; font-size: 12px; color: #78716c; }
|
|
247
|
+
</style>
|
|
248
|
+
</head>
|
|
249
|
+
<body>
|
|
250
|
+
<form class="card" action="/relay/admin/login" method="get">
|
|
251
|
+
<h1>Relay 使用统计</h1>
|
|
252
|
+
<p>请输入管理口令,查看今天有哪些租户正在使用你的 PromptX Relay。</p>
|
|
253
|
+
${escapedError ? `<div class="error">${escapedError}</div>` : ''}
|
|
254
|
+
<input type="hidden" name="redirect" value="${escapedRedirect}" />
|
|
255
|
+
<label for="token">管理口令</label>
|
|
256
|
+
<input id="token" name="token" type="password" autocomplete="current-password" required />
|
|
257
|
+
<button type="submit">进入统计页</button>
|
|
258
|
+
<div class="hint">可通过环境变量 <code>PROMPTX_RELAY_ADMIN_TOKEN</code> 配置。</div>
|
|
259
|
+
</form>
|
|
260
|
+
</body>
|
|
261
|
+
</html>`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildRelayUsagePage() {
|
|
265
|
+
return `<!doctype html>
|
|
266
|
+
<html lang="zh-CN">
|
|
267
|
+
<head>
|
|
268
|
+
<meta charset="utf-8" />
|
|
269
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
270
|
+
<title>PromptX Relay 使用统计</title>
|
|
271
|
+
<style>
|
|
272
|
+
:root { color-scheme: light; }
|
|
273
|
+
* { box-sizing: border-box; }
|
|
274
|
+
body { margin: 0; background: #f5f5f4; color: #1c1917; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
275
|
+
main { max-width: 1160px; margin: 0 auto; padding: 28px 18px 40px; }
|
|
276
|
+
.header { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
|
277
|
+
.title { margin: 0; font-size: 28px; }
|
|
278
|
+
.muted { color: #57534e; }
|
|
279
|
+
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
|
280
|
+
.toolbar select, .toolbar button { height: 38px; border: 1px solid #d6d3d1; background: white; padding: 0 12px; }
|
|
281
|
+
.toolbar button { cursor: pointer; }
|
|
282
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 16px 0 20px; }
|
|
283
|
+
.card { border: 1px solid #d6d3d1; background: #ffffff; box-shadow: 6px 6px 0 rgba(28,25,23,.04); padding: 16px; }
|
|
284
|
+
.card h2 { margin: 0 0 8px; font-size: 13px; color: #57534e; font-weight: 600; }
|
|
285
|
+
.metric { font-size: 30px; font-weight: 700; }
|
|
286
|
+
.metric-sub { margin-top: 6px; color: #78716c; font-size: 12px; }
|
|
287
|
+
.layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); gap: 14px; }
|
|
288
|
+
.panel-title { margin: 0 0 12px; font-size: 16px; }
|
|
289
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
290
|
+
th, td { border-top: 1px solid #e7e5e4; padding: 10px 8px; text-align: left; vertical-align: top; }
|
|
291
|
+
th { border-top: 0; color: #57534e; font-weight: 600; font-size: 12px; }
|
|
292
|
+
tbody tr:hover { background: #fafaf9; }
|
|
293
|
+
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border: 1px dashed #a8a29e; font-size: 12px; color: #44403c; }
|
|
294
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
295
|
+
.day-list { display: grid; gap: 10px; }
|
|
296
|
+
.day-row { border: 1px dashed #d6d3d1; background: #fafaf9; padding: 10px 12px; }
|
|
297
|
+
.day-row strong { display: block; margin-bottom: 4px; }
|
|
298
|
+
.empty { padding: 24px; border: 1px dashed #d6d3d1; background: #fafaf9; color: #78716c; text-align: center; }
|
|
299
|
+
.error { padding: 14px 16px; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; }
|
|
300
|
+
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
|
|
301
|
+
</style>
|
|
302
|
+
</head>
|
|
303
|
+
<body>
|
|
304
|
+
<main>
|
|
305
|
+
<div class="header">
|
|
306
|
+
<div>
|
|
307
|
+
<h1 class="title">Relay 使用统计</h1>
|
|
308
|
+
<div id="generatedAt" class="muted">读取中...</div>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="toolbar">
|
|
311
|
+
<label class="muted" for="days">最近</label>
|
|
312
|
+
<select id="days">
|
|
313
|
+
<option value="7">7 天</option>
|
|
314
|
+
<option value="14">14 天</option>
|
|
315
|
+
<option value="30">30 天</option>
|
|
316
|
+
</select>
|
|
317
|
+
<button id="refreshBtn" type="button">刷新</button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
<section class="grid" id="summaryCards"></section>
|
|
322
|
+
|
|
323
|
+
<div class="layout">
|
|
324
|
+
<section class="card">
|
|
325
|
+
<h2 class="panel-title">今日活跃租户</h2>
|
|
326
|
+
<div id="todayTableWrap"></div>
|
|
327
|
+
</section>
|
|
328
|
+
<section class="card">
|
|
329
|
+
<h2 class="panel-title">最近几天</h2>
|
|
330
|
+
<div id="recentDays"></div>
|
|
331
|
+
</section>
|
|
332
|
+
</div>
|
|
333
|
+
</main>
|
|
334
|
+
|
|
335
|
+
<script>
|
|
336
|
+
const generatedAtEl = document.getElementById('generatedAt')
|
|
337
|
+
const summaryCardsEl = document.getElementById('summaryCards')
|
|
338
|
+
const todayTableWrapEl = document.getElementById('todayTableWrap')
|
|
339
|
+
const recentDaysEl = document.getElementById('recentDays')
|
|
340
|
+
const daysSelectEl = document.getElementById('days')
|
|
341
|
+
const refreshBtnEl = document.getElementById('refreshBtn')
|
|
342
|
+
|
|
343
|
+
function escapeHtml(value) {
|
|
344
|
+
return String(value || '').replace(/[&<>"']/g, (char) => ({
|
|
345
|
+
'&': '&',
|
|
346
|
+
'<': '<',
|
|
347
|
+
'>': '>',
|
|
348
|
+
'"': '"',
|
|
349
|
+
"'": ''',
|
|
350
|
+
}[char] || char))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatDateTime(value) {
|
|
354
|
+
const raw = String(value || '').trim()
|
|
355
|
+
if (!raw) return '-'
|
|
356
|
+
const date = new Date(raw)
|
|
357
|
+
if (Number.isNaN(date.getTime())) return raw
|
|
358
|
+
return date.toLocaleString('zh-CN')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function renderSummary(today) {
|
|
362
|
+
const cards = [
|
|
363
|
+
{ label: '今日活跃租户', value: today.tenantCount || 0, sub: '至少连接过一次或转发过一次请求' },
|
|
364
|
+
{ label: '今日设备连接', value: today.connectCount || 0, sub: '设备成功连上 Relay 的次数' },
|
|
365
|
+
{ label: '今日转发请求', value: today.proxyRequestCount || 0, sub: '真实通过 Relay 转发到本地的请求数' },
|
|
366
|
+
{ label: '今日 API 请求', value: today.apiRequestCount || 0, sub: '只统计 /api/*' },
|
|
367
|
+
]
|
|
368
|
+
summaryCardsEl.innerHTML = cards.map((item) => \`
|
|
369
|
+
<section class="card">
|
|
370
|
+
<h2>\${escapeHtml(item.label)}</h2>
|
|
371
|
+
<div class="metric">\${escapeHtml(item.value)}</div>
|
|
372
|
+
<div class="metric-sub">\${escapeHtml(item.sub)}</div>
|
|
373
|
+
</section>
|
|
374
|
+
\`).join('')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function renderTodayTable(today) {
|
|
378
|
+
const tenants = Array.isArray(today.tenants) ? today.tenants : []
|
|
379
|
+
if (!tenants.length) {
|
|
380
|
+
todayTableWrapEl.innerHTML = '<div class="empty">今天还没有租户使用 Relay。</div>'
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
todayTableWrapEl.innerHTML = \`
|
|
385
|
+
<table>
|
|
386
|
+
<thead>
|
|
387
|
+
<tr>
|
|
388
|
+
<th>租户</th>
|
|
389
|
+
<th>连接</th>
|
|
390
|
+
<th>请求</th>
|
|
391
|
+
<th>最近设备</th>
|
|
392
|
+
<th>最近活跃</th>
|
|
393
|
+
</tr>
|
|
394
|
+
</thead>
|
|
395
|
+
<tbody>
|
|
396
|
+
\${tenants.map((item) => \`
|
|
397
|
+
<tr>
|
|
398
|
+
<td>
|
|
399
|
+
<div><strong>\${escapeHtml(item.tenantKey)}</strong></div>
|
|
400
|
+
<div class="muted mono">\${escapeHtml(item.host || '-')}</div>
|
|
401
|
+
</td>
|
|
402
|
+
<td>\${escapeHtml(item.connectCount || 0)}</td>
|
|
403
|
+
<td>
|
|
404
|
+
<div>\${escapeHtml(item.proxyRequestCount || 0)}</div>
|
|
405
|
+
<div class="muted">API \${escapeHtml(item.apiRequestCount || 0)} / 上传 \${escapeHtml(item.uploadRequestCount || 0)}</div>
|
|
406
|
+
</td>
|
|
407
|
+
<td class="mono">\${escapeHtml(item.lastDeviceId || '-')}</td>
|
|
408
|
+
<td>\${escapeHtml(formatDateTime(item.lastSeenAt))}</td>
|
|
409
|
+
</tr>
|
|
410
|
+
\`).join('')}
|
|
411
|
+
</tbody>
|
|
412
|
+
</table>
|
|
413
|
+
\`
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function renderRecentDays(days) {
|
|
417
|
+
if (!Array.isArray(days) || !days.length) {
|
|
418
|
+
recentDaysEl.innerHTML = '<div class="empty">还没有历史统计。</div>'
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
recentDaysEl.innerHTML = '<div class="day-list">' + days.map((item) => \`
|
|
423
|
+
<div class="day-row">
|
|
424
|
+
<strong>\${escapeHtml(item.date)}</strong>
|
|
425
|
+
<div class="muted">活跃租户 \${escapeHtml(item.tenantCount || 0)} / 连接 \${escapeHtml(item.connectCount || 0)} / 请求 \${escapeHtml(item.proxyRequestCount || 0)}</div>
|
|
426
|
+
</div>
|
|
427
|
+
\`).join('') + '</div>'
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function loadUsage() {
|
|
431
|
+
generatedAtEl.textContent = '读取中...'
|
|
432
|
+
todayTableWrapEl.innerHTML = ''
|
|
433
|
+
recentDaysEl.innerHTML = ''
|
|
434
|
+
const days = Number(daysSelectEl.value || 7) || 7
|
|
435
|
+
try {
|
|
436
|
+
const response = await fetch('/relay/admin/api/usage?days=' + encodeURIComponent(days), {
|
|
437
|
+
credentials: 'include',
|
|
438
|
+
headers: { accept: 'application/json' },
|
|
439
|
+
})
|
|
440
|
+
if (!response.ok) {
|
|
441
|
+
const payload = await response.json().catch(() => null)
|
|
442
|
+
throw new Error(payload && payload.message ? payload.message : '统计读取失败')
|
|
443
|
+
}
|
|
444
|
+
const payload = await response.json()
|
|
445
|
+
generatedAtEl.textContent = '最近更新:' + formatDateTime(payload.generatedAt)
|
|
446
|
+
renderSummary(payload.today || {})
|
|
447
|
+
renderTodayTable(payload.today || {})
|
|
448
|
+
renderRecentDays(payload.recentDays || [])
|
|
449
|
+
} catch (error) {
|
|
450
|
+
summaryCardsEl.innerHTML = ''
|
|
451
|
+
const message = escapeHtml(error && error.message ? error.message : '统计读取失败')
|
|
452
|
+
todayTableWrapEl.innerHTML = '<div class="error">' + message + '</div>'
|
|
453
|
+
recentDaysEl.innerHTML = ''
|
|
454
|
+
generatedAtEl.textContent = '读取失败'
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
daysSelectEl.addEventListener('change', loadUsage)
|
|
459
|
+
refreshBtnEl.addEventListener('click', loadUsage)
|
|
460
|
+
loadUsage()
|
|
461
|
+
</script>
|
|
462
|
+
</body>
|
|
463
|
+
</html>`
|
|
464
|
+
}
|
|
465
|
+
|
|
222
466
|
function getRequestPath(request) {
|
|
223
467
|
return String(request.raw.url || '/').split('?')[0] || '/'
|
|
224
468
|
}
|
|
@@ -239,6 +483,11 @@ function normalizeRedirectPath(value = '/') {
|
|
|
239
483
|
return raw
|
|
240
484
|
}
|
|
241
485
|
|
|
486
|
+
function normalizeAdminRedirectPath(value = '/relay/admin/usage') {
|
|
487
|
+
const normalized = normalizeRedirectPath(value || '/relay/admin/usage')
|
|
488
|
+
return normalized.startsWith('/relay/admin') ? normalized : '/relay/admin/usage'
|
|
489
|
+
}
|
|
490
|
+
|
|
242
491
|
function createCookieValue(name, value, secure = false) {
|
|
243
492
|
return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
|
|
244
493
|
}
|
|
@@ -327,6 +576,9 @@ async function startRelayServer(options = {}) {
|
|
|
327
576
|
const requestMap = new Map()
|
|
328
577
|
const tenantStateMap = new Map(config.tenants.map((tenant) => [tenant.key, createTenantState(tenant)]))
|
|
329
578
|
const tenantConfigMap = new Map(config.tenants.map((tenant) => [tenant.key, tenant]))
|
|
579
|
+
const usageStore = options.usageStore || createRelayUsageStore({
|
|
580
|
+
filePath: config.usageFile || undefined,
|
|
581
|
+
})
|
|
330
582
|
const heartbeatIntervalMs = Math.max(100, Number(options.heartbeatIntervalMs) || DEFAULT_HEARTBEAT_INTERVAL_MS)
|
|
331
583
|
const heartbeatTimeoutMs = Math.max(
|
|
332
584
|
heartbeatIntervalMs,
|
|
@@ -408,6 +660,37 @@ async function startRelayServer(options = {}) {
|
|
|
408
660
|
return reply.code(401).send({ message: '未通过 relay 访问验证。' })
|
|
409
661
|
}
|
|
410
662
|
|
|
663
|
+
function isAdminAuthorized(request) {
|
|
664
|
+
if (!config.adminToken) {
|
|
665
|
+
return true
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const bearerToken = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '').trim()
|
|
669
|
+
if (bearerToken && constantTimeEqual(bearerToken, config.adminToken)) {
|
|
670
|
+
return true
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const cookies = parseCookieHeader(request.headers.cookie)
|
|
674
|
+
return constantTimeEqual(cookies[config.adminCookieName] || '', config.adminToken)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function ensureAdminAuthorized(request, reply) {
|
|
678
|
+
if (isAdminAuthorized(request)) {
|
|
679
|
+
return true
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (isHtmlRequest(request)) {
|
|
683
|
+
return reply
|
|
684
|
+
.code(401)
|
|
685
|
+
.type('text/html; charset=utf-8')
|
|
686
|
+
.send(buildAdminLoginPage({
|
|
687
|
+
redirectPath: normalizeAdminRedirectPath(request.query?.redirect || getRequestPath(request)),
|
|
688
|
+
}))
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return reply.code(401).send({ message: '未通过 Relay 管理验证。' })
|
|
692
|
+
}
|
|
693
|
+
|
|
411
694
|
function getActiveDeviceSocket(tenantKey) {
|
|
412
695
|
const tenantState = getTenantState(tenantKey)
|
|
413
696
|
if (!tenantState?.socket || tenantState.socket.readyState !== 1) {
|
|
@@ -524,6 +807,13 @@ async function startRelayServer(options = {}) {
|
|
|
524
807
|
|
|
525
808
|
try {
|
|
526
809
|
sendRequestToDevice(deviceSocket, requestId, request)
|
|
810
|
+
usageStore.record({
|
|
811
|
+
tenantKey: tenant.key,
|
|
812
|
+
type: 'proxy_request',
|
|
813
|
+
host: getRequestHost(request),
|
|
814
|
+
deviceId: getTenantState(tenant.key)?.deviceId || '',
|
|
815
|
+
path: String(request.raw.url || '/'),
|
|
816
|
+
})
|
|
527
817
|
} catch (error) {
|
|
528
818
|
const record = requestMap.get(requestId)
|
|
529
819
|
requestMap.delete(requestId)
|
|
@@ -591,6 +881,47 @@ async function startRelayServer(options = {}) {
|
|
|
591
881
|
}
|
|
592
882
|
})
|
|
593
883
|
|
|
884
|
+
app.get('/relay/admin/login', async (request, reply) => {
|
|
885
|
+
if (!config.adminToken) {
|
|
886
|
+
return reply.redirect('/relay/admin/usage')
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const token = String(request.query?.token || '').trim()
|
|
890
|
+
const redirectPath = normalizeAdminRedirectPath(request.query?.redirect)
|
|
891
|
+
if (token && constantTimeEqual(token, config.adminToken)) {
|
|
892
|
+
reply.header('Set-Cookie', createCookieValue(config.adminCookieName, config.adminToken, isHttpsRequest(request)))
|
|
893
|
+
return reply.redirect(redirectPath)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return reply
|
|
897
|
+
.code(token ? 401 : 200)
|
|
898
|
+
.type('text/html; charset=utf-8')
|
|
899
|
+
.send(buildAdminLoginPage({
|
|
900
|
+
errorMessage: token ? '管理口令不正确。' : '',
|
|
901
|
+
redirectPath,
|
|
902
|
+
}))
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
app.get('/relay/admin/api/usage', async (request, reply) => {
|
|
906
|
+
if (ensureAdminAuthorized(request, reply) !== true) {
|
|
907
|
+
return
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const days = Math.max(1, Math.min(90, Number(request.query?.days) || 7))
|
|
911
|
+
return {
|
|
912
|
+
ok: true,
|
|
913
|
+
...usageStore.getReport({ days }),
|
|
914
|
+
}
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
app.get('/relay/admin/usage', async (request, reply) => {
|
|
918
|
+
if (ensureAdminAuthorized(request, reply) !== true) {
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return reply.type('text/html; charset=utf-8').send(buildRelayUsagePage())
|
|
923
|
+
})
|
|
924
|
+
|
|
594
925
|
app.get('/relay/login', async (request, reply) => {
|
|
595
926
|
const tenant = requireTenantRequest(request, reply)
|
|
596
927
|
if (!tenant) {
|
|
@@ -812,6 +1143,12 @@ async function startRelayServer(options = {}) {
|
|
|
812
1143
|
tenantState.lastDisconnectReason = ''
|
|
813
1144
|
tenantState.version = String(message.version || '').trim()
|
|
814
1145
|
}
|
|
1146
|
+
usageStore.record({
|
|
1147
|
+
tenantKey: tenant.key,
|
|
1148
|
+
type: 'connect',
|
|
1149
|
+
host: tenant.hosts[0] || '',
|
|
1150
|
+
deviceId: providedDeviceId || '',
|
|
1151
|
+
})
|
|
815
1152
|
appendTenantEvent(tenant.key, 'auth_ok', {
|
|
816
1153
|
deviceId: providedDeviceId || '',
|
|
817
1154
|
version: String(message.version || '').trim(),
|
|
@@ -944,6 +1281,12 @@ async function startRelayServer(options = {}) {
|
|
|
944
1281
|
const accessUrl = `http://${config.host === '0.0.0.0' ? '127.0.0.1' : config.host}:${resolvedPort}`
|
|
945
1282
|
app.log.info(`promptx relay running at ${accessUrl}`)
|
|
946
1283
|
app.log.info(`[relay] 已加载 ${config.tenants.length} 个租户,来源:${config.tenantSource}`)
|
|
1284
|
+
app.log.info({ usageFile: usageStore.filePath }, '[relay] 租户使用统计已启用')
|
|
1285
|
+
if (config.adminToken) {
|
|
1286
|
+
app.log.info({ adminPath: '/relay/admin/usage' }, '[relay] 管理统计页面已启用')
|
|
1287
|
+
} else {
|
|
1288
|
+
app.log.warn({ adminPath: '/relay/admin/usage' }, '[relay] 管理统计页面未配置口令,当前可直接访问')
|
|
1289
|
+
}
|
|
947
1290
|
app.log.info({
|
|
948
1291
|
heartbeatIntervalMs,
|
|
949
1292
|
heartbeatTimeoutMs,
|
|
@@ -963,6 +1306,7 @@ async function startRelayServer(options = {}) {
|
|
|
963
1306
|
port: resolvedPort,
|
|
964
1307
|
async close() {
|
|
965
1308
|
clearInterval(heartbeatTimer)
|
|
1309
|
+
usageStore.flush?.()
|
|
966
1310
|
await new Promise((resolve) => {
|
|
967
1311
|
try {
|
|
968
1312
|
wsServer.close(() => resolve())
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { ensurePromptxStorageReady } from './appPaths.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RETENTION_DAYS = 90
|
|
7
|
+
const DEFAULT_FLUSH_DELAY_MS = 300
|
|
8
|
+
|
|
9
|
+
function getLocalDateKey(input = new Date()) {
|
|
10
|
+
const date = input instanceof Date ? input : new Date(input)
|
|
11
|
+
const year = date.getFullYear()
|
|
12
|
+
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
13
|
+
const day = `${date.getDate()}`.padStart(2, '0')
|
|
14
|
+
return `${year}-${month}-${day}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function clampRetentionDays(value) {
|
|
18
|
+
return Math.max(1, Number(value) || DEFAULT_RETENTION_DAYS)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildDefaultState() {
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
updatedAt: '',
|
|
25
|
+
days: {},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function safeReadJson(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeEntry(raw = {}, tenantKey = '') {
|
|
38
|
+
return {
|
|
39
|
+
tenantKey: String(raw?.tenantKey || tenantKey || '').trim(),
|
|
40
|
+
host: String(raw?.host || '').trim(),
|
|
41
|
+
firstSeenAt: String(raw?.firstSeenAt || '').trim(),
|
|
42
|
+
lastSeenAt: String(raw?.lastSeenAt || '').trim(),
|
|
43
|
+
lastConnectAt: String(raw?.lastConnectAt || '').trim(),
|
|
44
|
+
lastRequestAt: String(raw?.lastRequestAt || '').trim(),
|
|
45
|
+
lastDeviceId: String(raw?.lastDeviceId || '').trim(),
|
|
46
|
+
connectCount: Math.max(0, Number(raw?.connectCount) || 0),
|
|
47
|
+
proxyRequestCount: Math.max(0, Number(raw?.proxyRequestCount) || 0),
|
|
48
|
+
apiRequestCount: Math.max(0, Number(raw?.apiRequestCount) || 0),
|
|
49
|
+
uploadRequestCount: Math.max(0, Number(raw?.uploadRequestCount) || 0),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeState(raw = {}) {
|
|
54
|
+
const next = buildDefaultState()
|
|
55
|
+
const days = raw && typeof raw === 'object' ? raw.days : null
|
|
56
|
+
if (!days || typeof days !== 'object') {
|
|
57
|
+
return next
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Object.entries(days).forEach(([dateKey, entries]) => {
|
|
61
|
+
if (!entries || typeof entries !== 'object') {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
next.days[dateKey] = Object.fromEntries(
|
|
65
|
+
Object.entries(entries)
|
|
66
|
+
.map(([tenantKey, item]) => [tenantKey, normalizeEntry(item, tenantKey)])
|
|
67
|
+
.filter(([, item]) => item.tenantKey)
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
next.updatedAt = String(raw?.updatedAt || '').trim()
|
|
72
|
+
return next
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sortEntries(entries = []) {
|
|
76
|
+
return [...entries].sort((left, right) => {
|
|
77
|
+
const rightSeen = Date.parse(right.lastSeenAt || '') || 0
|
|
78
|
+
const leftSeen = Date.parse(left.lastSeenAt || '') || 0
|
|
79
|
+
if (rightSeen !== leftSeen) {
|
|
80
|
+
return rightSeen - leftSeen
|
|
81
|
+
}
|
|
82
|
+
if ((right.proxyRequestCount || 0) !== (left.proxyRequestCount || 0)) {
|
|
83
|
+
return (right.proxyRequestCount || 0) - (left.proxyRequestCount || 0)
|
|
84
|
+
}
|
|
85
|
+
return String(left.tenantKey || '').localeCompare(String(right.tenantKey || ''))
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function summarizeDay(dateKey, entryMap = {}) {
|
|
90
|
+
const tenants = sortEntries(Object.values(entryMap || {}))
|
|
91
|
+
return {
|
|
92
|
+
date: dateKey,
|
|
93
|
+
tenantCount: tenants.length,
|
|
94
|
+
connectCount: tenants.reduce((sum, item) => sum + (item.connectCount || 0), 0),
|
|
95
|
+
proxyRequestCount: tenants.reduce((sum, item) => sum + (item.proxyRequestCount || 0), 0),
|
|
96
|
+
apiRequestCount: tenants.reduce((sum, item) => sum + (item.apiRequestCount || 0), 0),
|
|
97
|
+
uploadRequestCount: tenants.reduce((sum, item) => sum + (item.uploadRequestCount || 0), 0),
|
|
98
|
+
tenants,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createFileWriter(filePath) {
|
|
103
|
+
let flushTimer = null
|
|
104
|
+
let pendingState = null
|
|
105
|
+
|
|
106
|
+
function flushNow() {
|
|
107
|
+
if (!pendingState) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
111
|
+
fs.writeFileSync(filePath, `${JSON.stringify(pendingState, null, 2)}\n`, 'utf8')
|
|
112
|
+
pendingState = null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
schedule(state) {
|
|
117
|
+
pendingState = state
|
|
118
|
+
if (flushTimer) {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
flushTimer = setTimeout(() => {
|
|
122
|
+
flushTimer = null
|
|
123
|
+
flushNow()
|
|
124
|
+
}, DEFAULT_FLUSH_DELAY_MS)
|
|
125
|
+
flushTimer.unref?.()
|
|
126
|
+
},
|
|
127
|
+
flush() {
|
|
128
|
+
if (flushTimer) {
|
|
129
|
+
clearTimeout(flushTimer)
|
|
130
|
+
flushTimer = null
|
|
131
|
+
}
|
|
132
|
+
flushNow()
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getDefaultRelayUsageFilePath() {
|
|
138
|
+
const { dataDir } = ensurePromptxStorageReady()
|
|
139
|
+
return path.join(dataDir, 'relay-usage.json')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createRelayUsageStore(options = {}) {
|
|
143
|
+
const filePath = path.resolve(String(options.filePath || getDefaultRelayUsageFilePath()).trim())
|
|
144
|
+
const retentionDays = clampRetentionDays(options.retentionDays)
|
|
145
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date()
|
|
146
|
+
const writer = createFileWriter(filePath)
|
|
147
|
+
let state = normalizeState(safeReadJson(filePath))
|
|
148
|
+
|
|
149
|
+
function markDirty() {
|
|
150
|
+
state.updatedAt = new Date(now()).toISOString()
|
|
151
|
+
writer.schedule(state)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function prune() {
|
|
155
|
+
const keys = Object.keys(state.days || {}).sort()
|
|
156
|
+
if (keys.length <= retentionDays) {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
const toDelete = keys.slice(0, Math.max(0, keys.length - retentionDays))
|
|
160
|
+
toDelete.forEach((key) => {
|
|
161
|
+
delete state.days[key]
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ensureDayEntry(dateKey, tenantKey) {
|
|
166
|
+
if (!state.days[dateKey]) {
|
|
167
|
+
state.days[dateKey] = {}
|
|
168
|
+
}
|
|
169
|
+
if (!state.days[dateKey][tenantKey]) {
|
|
170
|
+
state.days[dateKey][tenantKey] = normalizeEntry({ tenantKey }, tenantKey)
|
|
171
|
+
}
|
|
172
|
+
return state.days[dateKey][tenantKey]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function record(event = {}) {
|
|
176
|
+
const tenantKey = String(event?.tenantKey || '').trim()
|
|
177
|
+
if (!tenantKey) {
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const at = new Date(event?.at || now())
|
|
182
|
+
const atIso = at.toISOString()
|
|
183
|
+
const dateKey = getLocalDateKey(at)
|
|
184
|
+
const entry = ensureDayEntry(dateKey, tenantKey)
|
|
185
|
+
|
|
186
|
+
entry.tenantKey = tenantKey
|
|
187
|
+
entry.host = String(event?.host || entry.host || '').trim()
|
|
188
|
+
entry.lastDeviceId = String(event?.deviceId || entry.lastDeviceId || '').trim()
|
|
189
|
+
entry.firstSeenAt = entry.firstSeenAt || atIso
|
|
190
|
+
entry.lastSeenAt = atIso
|
|
191
|
+
|
|
192
|
+
const type = String(event?.type || '').trim()
|
|
193
|
+
if (type === 'connect') {
|
|
194
|
+
entry.connectCount += 1
|
|
195
|
+
entry.lastConnectAt = atIso
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (type === 'proxy_request') {
|
|
199
|
+
entry.proxyRequestCount += 1
|
|
200
|
+
entry.lastRequestAt = atIso
|
|
201
|
+
const requestPath = String(event?.path || '').trim()
|
|
202
|
+
if (requestPath.startsWith('/api/')) {
|
|
203
|
+
entry.apiRequestCount += 1
|
|
204
|
+
} else if (requestPath.startsWith('/uploads/')) {
|
|
205
|
+
entry.uploadRequestCount += 1
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
prune()
|
|
210
|
+
markDirty()
|
|
211
|
+
return { dateKey, entry: { ...entry } }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getReport({ days = 7, today = now() } = {}) {
|
|
215
|
+
const todayKey = getLocalDateKey(today)
|
|
216
|
+
const availableKeys = Object.keys(state.days || {}).sort().reverse()
|
|
217
|
+
const selectedKeys = availableKeys.slice(0, Math.max(1, Number(days) || 7))
|
|
218
|
+
const dayBuckets = selectedKeys.map((dateKey) => summarizeDay(dateKey, state.days[dateKey]))
|
|
219
|
+
const todayBucket = dayBuckets.find((item) => item.date === todayKey) || summarizeDay(todayKey, state.days[todayKey] || {})
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
generatedAt: new Date(now()).toISOString(),
|
|
223
|
+
today: todayBucket,
|
|
224
|
+
recentDays: dayBuckets,
|
|
225
|
+
filePath,
|
|
226
|
+
retentionDays,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
filePath,
|
|
232
|
+
record,
|
|
233
|
+
getReport,
|
|
234
|
+
flush() {
|
|
235
|
+
writer.flush()
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export {
|
|
241
|
+
getLocalDateKey,
|
|
242
|
+
}
|
package/docs/relay-quickstart.md
CHANGED
|
@@ -141,6 +141,8 @@ promptx relay stop
|
|
|
141
141
|
promptx relay restart
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
如果只是升级 Relay 的租户统计能力,只需要在云端更新代码并重启 Relay,本地同事的 PromptX 不需要升级。
|
|
145
|
+
|
|
144
146
|
### 7. 发给同事的信息
|
|
145
147
|
|
|
146
148
|
新增好租户后,把这 4 项发给同事:
|
|
@@ -164,7 +166,36 @@ curl -H 'Host: user1.promptx.mushayu.com' http://127.0.0.1:3030/health
|
|
|
164
166
|
{"ok":true,"tenant":"user1","host":"user1.promptx.mushayu.com","deviceOnline":true}
|
|
165
167
|
```
|
|
166
168
|
|
|
167
|
-
### 9.
|
|
169
|
+
### 9. 查看使用统计
|
|
170
|
+
|
|
171
|
+
Relay 现在自带一个轻量统计页,用来看“今天有哪些租户实际使用了 Relay”。
|
|
172
|
+
|
|
173
|
+
直接打开:
|
|
174
|
+
|
|
175
|
+
```text
|
|
176
|
+
https://你的域名/relay/admin/usage
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
如果你没有配置管理口令,这个页面可直接访问;如果后面想加保护,可以额外配置:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
export PROMPTX_RELAY_ADMIN_TOKEN=你自己的管理口令
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
统计页会展示:
|
|
186
|
+
|
|
187
|
+
- 今日活跃租户数
|
|
188
|
+
- 今日设备连接次数
|
|
189
|
+
- 今日转发请求数
|
|
190
|
+
- 每个租户的最近活跃时间、最近设备、API 请求数、上传请求数
|
|
191
|
+
|
|
192
|
+
统计文件默认保存在:
|
|
193
|
+
|
|
194
|
+
```text
|
|
195
|
+
~/.promptx/data/relay-usage.json
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 10. Nginx 配置
|
|
168
199
|
|
|
169
200
|
建议让 Nginx 负责 HTTPS,并把请求转发到本机 `3030`:
|
|
170
201
|
|
|
@@ -194,7 +225,7 @@ server {
|
|
|
194
225
|
}
|
|
195
226
|
```
|
|
196
227
|
|
|
197
|
-
###
|
|
228
|
+
### 11. DNS 配置
|
|
198
229
|
|
|
199
230
|
最省事的是泛解析:
|
|
200
231
|
|
|
@@ -202,7 +233,7 @@ server {
|
|
|
202
233
|
*.promptx.mushayu.com -> 云服务器公网 IP
|
|
203
234
|
```
|
|
204
235
|
|
|
205
|
-
###
|
|
236
|
+
### 12. 常见问题
|
|
206
237
|
|
|
207
238
|
- `设备令牌不匹配`
|
|
208
239
|
- 同事本地填写的 `deviceToken` 不对
|
|
@@ -213,7 +244,7 @@ server {
|
|
|
213
244
|
- `503 PromptX 本地设备暂未连接到 relay`
|
|
214
245
|
- 云端 Relay 正常,但对应同事的本机 PromptX 没连上
|
|
215
246
|
|
|
216
|
-
###
|
|
247
|
+
### 13. 开发环境源码启动
|
|
217
248
|
|
|
218
249
|
如果不是 npm 安装版,而是源码测试:
|
|
219
250
|
|