@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 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, '&quot;')
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
+ '&': '&amp;',
346
+ '<': '&lt;',
347
+ '>': '&gt;',
348
+ '"': '&quot;',
349
+ "'": '&#39;',
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
+ }
@@ -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. Nginx 配置
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
- ### 10. DNS 配置
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
- ### 11. 常见问题
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
- ### 12. 开发环境源码启动
247
+ ### 13. 开发环境源码启动
217
248
 
218
249
  如果不是 npm 安装版,而是源码测试:
219
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muyichengshayu/promptx",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "PromptX 本机 AI 协作工作台",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",