dqy08 commited on
Commit
3c2da0a
·
1 Parent(s): ca74a9e

增加「当前在线人数」估计

Browse files
backend/api/client_activity.py CHANGED
@@ -1,9 +1,15 @@
1
  from urllib.parse import unquote
2
 
 
3
  from backend.platform.access_log import log_request
 
4
  from backend.platform.visit_stats import normalize_page_key, record_activity_report, record_gen_attr_opt_sec
5
 
6
 
 
 
 
 
7
  def _sparse_page_activity_log_cum(cum: int) -> bool:
8
  """无服务端状态:累计秒为 2、10、20(前 20s 内与前端档位一致)或 40、80、160…(40·2^k)时打访问日志。"""
9
  if cum in (2, 10, 20):
@@ -21,13 +27,13 @@ def client_activity_report(activity_body=None):
21
  cum = int(d.get("total_active_sec"))
22
  dlt = int(d.get("delta_active_sec"))
23
  if not p or cum < 1 or dlt < 0:
24
- return {"ok": True}
25
  except (TypeError, ValueError):
26
- return {"ok": True}
27
  path_only = p.split("?", 1)[0].split("#", 1)[0].strip()
28
  page_key = path_only.rstrip("/").split("/")[-1] or path_only
29
  if not page_key:
30
- return {"ok": True}
31
  if "?" in p:
32
  qs = p.split("?", 1)[1].split("#", 1)[0]
33
  log_path = f"{page_key}?{unquote(qs)}" if qs else page_key
@@ -36,17 +42,19 @@ def client_activity_report(activity_body=None):
36
 
37
  page_key = normalize_page_key(page_key)
38
 
39
- raw_os = d.get("client_os")
40
- client_os = str(raw_os).strip() if raw_os is not None else None
41
-
42
- record_activity_report(page_key, dlt, cum, client_os)
43
- if page_key == "causal_flow.html":
44
- raw_opts = d.get("page_opts")
45
- if isinstance(raw_opts, dict):
46
- record_gen_attr_opt_sec(dlt, {k: bool(v) for k, v in raw_opts.items() if isinstance(k, str)})
47
- if _sparse_page_activity_log_cum(cum):
48
- log_request(
49
- "📄 页面活跃",
50
- f"path(sampled)={log_path!r} total_sec={cum} delta_sec={dlt}",
51
- )
52
- return {"ok": True}
 
 
 
1
  from urllib.parse import unquote
2
 
3
+ from backend.api.utils import request_has_valid_admin
4
  from backend.platform.access_log import log_request
5
+ from backend.platform.online_presence import WINDOW_SEC, get_online_now, record_heartbeat
6
  from backend.platform.visit_stats import normalize_page_key, record_activity_report, record_gen_attr_opt_sec
7
 
8
 
9
+ def _activity_response() -> dict:
10
+ return {"ok": True, "online_now": get_online_now(), "online_window_sec": WINDOW_SEC}
11
+
12
+
13
  def _sparse_page_activity_log_cum(cum: int) -> bool:
14
  """无服务端状态:累计秒为 2、10、20(前 20s 内与前端档位一致)或 40、80、160…(40·2^k)时打访问日志。"""
15
  if cum in (2, 10, 20):
 
27
  cum = int(d.get("total_active_sec"))
28
  dlt = int(d.get("delta_active_sec"))
29
  if not p or cum < 1 or dlt < 0:
30
+ return _activity_response()
31
  except (TypeError, ValueError):
32
+ return _activity_response()
33
  path_only = p.split("?", 1)[0].split("#", 1)[0].strip()
34
  page_key = path_only.rstrip("/").split("/")[-1] or path_only
35
  if not page_key:
36
+ return _activity_response()
37
  if "?" in p:
38
  qs = p.split("?", 1)[1].split("#", 1)[0]
39
  log_path = f"{page_key}?{unquote(qs)}" if qs else page_key
 
42
 
43
  page_key = normalize_page_key(page_key)
44
 
45
+ if not request_has_valid_admin():
46
+ raw_os = d.get("client_os")
47
+ client_os = str(raw_os).strip() if raw_os is not None else None
48
+
49
+ record_heartbeat()
50
+ record_activity_report(page_key, dlt, cum, client_os)
51
+ if page_key == "causal_flow.html":
52
+ raw_opts = d.get("page_opts")
53
+ if isinstance(raw_opts, dict):
54
+ record_gen_attr_opt_sec(dlt, {k: bool(v) for k, v in raw_opts.items() if isinstance(k, str)})
55
+ if _sparse_page_activity_log_cum(cum):
56
+ log_request(
57
+ "📄 页面活跃",
58
+ f"path(sampled)={log_path!r} total_sec={cum} delta_sec={dlt}",
59
+ )
60
+ return _activity_response()
backend/platform/online_presence.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """由 /api/client-activity 心跳估算当前在线(滑动窗口,无会话 ID)。"""
2
+
3
+ import math
4
+ import threading
5
+ import time
6
+ from collections import deque
7
+
8
+ WINDOW_SEC = 20
9
+ _HEARTBEAT_PERIOD_SEC = 10
10
+
11
+ _LOCK = threading.Lock()
12
+ _beats: deque[float] = deque()
13
+
14
+
15
+ def record_heartbeat() -> None:
16
+ now = time.monotonic()
17
+ with _LOCK:
18
+ _beats.append(now)
19
+ _prune_locked(now)
20
+
21
+
22
+ def get_online_now() -> int:
23
+ now = time.monotonic()
24
+ with _LOCK:
25
+ _prune_locked(now)
26
+ n = len(_beats)
27
+ return math.ceil(n * _HEARTBEAT_PERIOD_SEC / WINDOW_SEC)
28
+
29
+
30
+ def _prune_locked(now: float) -> None:
31
+ cutoff = now - WINDOW_SEC
32
+ while _beats and _beats[0] < cutoff:
33
+ _beats.popleft()
backend/platform/visit_stats.py CHANGED
@@ -515,8 +515,12 @@ def _merge_from_sample(s: dict) -> tuple[dict, dict, dict]:
515
 
516
 
517
  def get_stats_snapshot() -> dict:
 
 
518
  sample = _sample_locked_counters()
519
  public, _, _ = _merge_from_sample(sample)
 
 
520
  public["server_platform"] = _get_server_platform()
521
  public["startup_base"] = _startup_base
522
  if _process_start_at is not None:
 
515
 
516
 
517
  def get_stats_snapshot() -> dict:
518
+ from backend.platform.online_presence import WINDOW_SEC, get_online_now
519
+
520
  sample = _sample_locked_counters()
521
  public, _, _ = _merge_from_sample(sample)
522
+ public["online_now"] = get_online_now()
523
+ public["online_window_sec"] = WINDOW_SEC
524
  public["server_platform"] = _get_server_platform()
525
  public["startup_base"] = _startup_base
526
  if _process_start_at is not None:
client/src/analysis.html CHANGED
@@ -21,9 +21,7 @@
21
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
22
  <a href="compare.html?showTextRender=1&demos=/quick-start-1.json,/quick-start-2.json" target="_blank" class="compare-link" style="display: none;" title="Compare analysis results" data-i18n="text,title">Compare results</a>
23
  <div class="settings-menu-wrapper">
24
- <button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
25
- <span class="settings-icon">⚙️</span>
26
- </button>
27
  <div id="settings_menu" class="settings-menu" style="display: none;">
28
  <!-- INCLUDE partials/settings-menu-analysis.html -->
29
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
 
21
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
22
  <a href="compare.html?showTextRender=1&demos=/quick-start-1.json,/quick-start-2.json" target="_blank" class="compare-link" style="display: none;" title="Compare analysis results" data-i18n="text,title">Compare results</a>
23
  <div class="settings-menu-wrapper">
24
+ <!-- INCLUDE partials/settings-toolbar-controls.html -->
 
 
25
  <div id="settings_menu" class="settings-menu" style="display: none;">
26
  <!-- INCLUDE partials/settings-menu-analysis.html -->
27
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
client/src/attribution.html CHANGED
@@ -18,9 +18,7 @@
18
  <div class="app-page-toolbar-actions">
19
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
20
  <div class="settings-menu-wrapper">
21
- <button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
22
- <span class="settings-icon">⚙️</span>
23
- </button>
24
  <div id="settings_menu" class="settings-menu" style="display: none;">
25
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
26
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
 
18
  <div class="app-page-toolbar-actions">
19
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
20
  <div class="settings-menu-wrapper">
21
+ <!-- INCLUDE partials/settings-toolbar-controls.html -->
 
 
22
  <div id="settings_menu" class="settings-menu" style="display: none;">
23
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
24
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
client/src/causal_flow.html CHANGED
@@ -18,9 +18,7 @@
18
  <div class="app-page-toolbar-actions">
19
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
20
  <div class="settings-menu-wrapper">
21
- <button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
22
- <span class="settings-icon">⚙️</span>
23
- </button>
24
  <div id="settings_menu" class="settings-menu" style="display: none;">
25
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
26
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
 
18
  <div class="app-page-toolbar-actions">
19
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
20
  <div class="settings-menu-wrapper">
21
+ <!-- INCLUDE partials/settings-toolbar-controls.html -->
 
 
22
  <div id="settings_menu" class="settings-menu" style="display: none;">
23
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
24
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
client/src/chat.html CHANGED
@@ -18,9 +18,7 @@
18
  <div class="app-page-toolbar-actions">
19
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
20
  <div class="settings-menu-wrapper">
21
- <button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
22
- <span class="settings-icon">⚙️</span>
23
- </button>
24
  <div id="settings_menu" class="settings-menu" style="display: none;">
25
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
26
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
 
18
  <div class="app-page-toolbar-actions">
19
  <a href="index.html" class="home-link" title="Info Lens" data-i18n="text,title">Info Lens</a>
20
  <div class="settings-menu-wrapper">
21
+ <!-- INCLUDE partials/settings-toolbar-controls.html -->
 
 
22
  <div id="settings_menu" class="settings-menu" style="display: none;">
23
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
24
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
client/src/css/components/_settings-menu.scss CHANGED
@@ -1,12 +1,37 @@
1
  // 设置菜单样式(Theme/Language 下拉等共用)
2
  .settings-menu-wrapper {
3
  position: relative;
4
- display: inline-block;
 
 
5
  font-family: var(--font-family-ui);
6
  font-size: var(--font-size-base);
7
  line-height: 1.4;
8
  }
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  .settings-btn {
11
  display: inline-flex;
12
  align-items: center;
 
1
  // 设置菜单样式(Theme/Language 下拉等共用)
2
  .settings-menu-wrapper {
3
  position: relative;
4
+ display: inline-flex;
5
+ align-items: center;
6
+ gap: 8px;
7
  font-family: var(--font-family-ui);
8
  font-size: var(--font-size-base);
9
  line-height: 1.4;
10
  }
11
 
12
+ .online-count {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ gap: 1px;
17
+ color: var(--text-muted, var(--text-color));
18
+ opacity: 0.85;
19
+ font-size: 11px;
20
+ line-height: 1.15;
21
+ user-select: none;
22
+ }
23
+
24
+ .online-count-label,
25
+ .online-count-value {
26
+ white-space: nowrap;
27
+ }
28
+
29
+ .online-count-value {
30
+ font-variant-numeric: tabular-nums;
31
+ min-width: 1.5ch;
32
+ text-align: center;
33
+ }
34
+
35
  .settings-btn {
36
  display: inline-flex;
37
  align-items: center;
client/src/index.html CHANGED
@@ -13,9 +13,7 @@
13
  <body class="nav-landing-page">
14
 
15
  <div class="nav-landing-settings settings-menu-wrapper">
16
- <button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
17
- <span class="settings-icon">⚙️</span>
18
- </button>
19
  <div id="settings_menu" class="settings-menu" style="display: none;">
20
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
21
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
 
13
  <body class="nav-landing-page">
14
 
15
  <div class="nav-landing-settings settings-menu-wrapper">
16
+ <!-- INCLUDE partials/settings-toolbar-controls.html -->
 
 
17
  <div id="settings_menu" class="settings-menu" style="display: none;">
18
  <!-- INCLUDE partials/settings-menu-common-mid.html -->
19
  <!-- INCLUDE partials/settings-menu-trailing-admin.html -->
client/src/pages/analysis/index.ts CHANGED
@@ -32,6 +32,7 @@ import {PANEL_SPLIT_STORAGE_KEY_START} from '../../shared/cross/panelSplitStorag
32
  import {handleServerDemoSave} from '../../shared/controllers/serverDemoController';
33
  // 公共初始化模块
34
  import {initializeCommonApp} from '../../shared/bootstrap';
 
35
  // 工具函数
36
  import {ensureJsonExtension} from '../../shared/cross/localFileUtils';
37
  import {extractErrorMessage} from '../../shared/core/errorUtils';
@@ -193,6 +194,10 @@ window.onload = () => {
193
  getSemanticSearchQuery: () => (document.getElementById('semantic_search_input') as HTMLInputElement | null)?.value ?? '',
194
  tr
195
  });
 
 
 
 
196
 
197
  // 创建GLTR文本可视化实例
198
  const lmf = new GLTR_Text_Box(d3.select("#results"), eventHandler);
 
32
  import {handleServerDemoSave} from '../../shared/controllers/serverDemoController';
33
  // 公共初始化模块
34
  import {initializeCommonApp} from '../../shared/bootstrap';
35
+ import { registerPageBusy } from '../../shared/core/activitySession';
36
  // 工具函数
37
  import {ensureJsonExtension} from '../../shared/cross/localFileUtils';
38
  import {extractErrorMessage} from '../../shared/core/errorUtils';
 
194
  getSemanticSearchQuery: () => (document.getElementById('semantic_search_input') as HTMLInputElement | null)?.value ?? '',
195
  tr
196
  });
197
+ registerPageBusy(() => {
198
+ const s = appStateManager.getState();
199
+ return appStateManager.getIsAnalyzing() || s.isSemanticSearching;
200
+ });
201
 
202
  // 创建GLTR文本可视化实例
203
  const lmf = new GLTR_Text_Box(d3.select("#results"), eventHandler);
client/src/pages/attribution/index.ts CHANGED
@@ -11,6 +11,7 @@ import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
11
  import { PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION } from '../../shared/cross/panelSplitStorage';
12
  import { TextInputController } from '../../shared/controllers/textInputController';
13
  import { initializeCommonApp } from '../../shared/bootstrap';
 
14
  import { showAlertDialog } from '../../shared/ui/dialog';
15
  import URLHandler from '../../shared/core/URLHandler';
16
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
@@ -165,6 +166,8 @@ function setAnalyzeLoading(loading: boolean): void {
165
  syncAnalyzeButtonState();
166
  }
167
 
 
 
168
  // input 事件同步按钮状态
169
  [contextField, targetField].forEach((field) => {
170
  (field.node() as HTMLTextAreaElement | null)?.addEventListener('input', syncAnalyzeButtonState);
 
11
  import { PANEL_SPLIT_STORAGE_KEY_ATTRIBUTION } from '../../shared/cross/panelSplitStorage';
12
  import { TextInputController } from '../../shared/controllers/textInputController';
13
  import { initializeCommonApp } from '../../shared/bootstrap';
14
+ import { registerPageBusy } from '../../shared/core/activitySession';
15
  import { showAlertDialog } from '../../shared/ui/dialog';
16
  import URLHandler from '../../shared/core/URLHandler';
17
  import { initCachedHistoryQueryDropdown, type CachedHistorySelectContext } from '../../shared/cross/cachedHistoryUi';
 
166
  syncAnalyzeButtonState();
167
  }
168
 
169
+ registerPageBusy(() => analyzeInFlight);
170
+
171
  // input 事件同步按钮状态
172
  [contextField, targetField].forEach((field) => {
173
  (field.node() as HTMLTextAreaElement | null)?.addEventListener('input', syncAnalyzeButtonState);
client/src/pages/causal_flow/index.ts CHANGED
@@ -11,6 +11,7 @@ import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
11
  import { PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE } from '../../shared/cross/panelSplitStorage';
12
  import { TextInputController } from '../../shared/controllers/textInputController';
13
  import { initializeCommonApp } from '../../shared/bootstrap';
 
14
  import { setPageOptsGetter } from '../../shared/core/clientActivityPing';
15
  import { showAlertDialog } from '../../shared/ui/dialog';
16
  import URLHandler from '../../shared/core/URLHandler';
@@ -1658,6 +1659,8 @@ function setGenLoading(loading: boolean): void {
1658
  syncSubmitButtonState();
1659
  }
1660
 
 
 
1661
  /** 当前输入是否满足可以发起一次生成(不含 inFlight 判断)。 */
1662
  function isInputReadyForRun(): boolean {
1663
  const prompt = getActivePromptValue();
 
11
  import { PANEL_SPLIT_STORAGE_KEY_GEN_ATTRIBUTE } from '../../shared/cross/panelSplitStorage';
12
  import { TextInputController } from '../../shared/controllers/textInputController';
13
  import { initializeCommonApp } from '../../shared/bootstrap';
14
+ import { registerPageBusy } from '../../shared/core/activitySession';
15
  import { setPageOptsGetter } from '../../shared/core/clientActivityPing';
16
  import { showAlertDialog } from '../../shared/ui/dialog';
17
  import URLHandler from '../../shared/core/URLHandler';
 
1659
  syncSubmitButtonState();
1660
  }
1661
 
1662
+ registerPageBusy(() => inFlight);
1663
+
1664
  /** 当前输入是否满足可以发起一次生成(不含 inFlight 判断)。 */
1665
  function isInputReadyForRun(): boolean {
1666
  const prompt = getActivePromptValue();
client/src/pages/chat/index.ts CHANGED
@@ -12,6 +12,7 @@ import { initChatPanelLayout } from '../../shared/ui/chat_panel_layout';
12
  import { PANEL_SPLIT_STORAGE_KEY_CHAT } from '../../shared/cross/panelSplitStorage';
13
  import { TextInputController } from '../../shared/controllers/textInputController';
14
  import { initializeCommonApp } from '../../shared/bootstrap';
 
15
  import { showAlertDialog } from '../../shared/ui/dialog';
16
  import URLHandler from '../../shared/core/URLHandler';
17
  import { ToolTip } from '../../shared/vis/ToolTip';
@@ -489,6 +490,8 @@ const setAskLoading = (loading: boolean): void => {
489
  syncAskButtonState();
490
  };
491
 
 
 
492
  const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
493
  const prompt = getActivePromptValue();
494
  if (askInFlight || prompt.length === 0) return;
 
12
  import { PANEL_SPLIT_STORAGE_KEY_CHAT } from '../../shared/cross/panelSplitStorage';
13
  import { TextInputController } from '../../shared/controllers/textInputController';
14
  import { initializeCommonApp } from '../../shared/bootstrap';
15
+ import { registerPageBusy } from '../../shared/core/activitySession';
16
  import { showAlertDialog } from '../../shared/ui/dialog';
17
  import URLHandler from '../../shared/core/URLHandler';
18
  import { ToolTip } from '../../shared/vis/ToolTip';
 
490
  syncAskButtonState();
491
  };
492
 
493
+ registerPageBusy(() => askInFlight);
494
+
495
  const runAsk = async (options?: { forceRefresh?: boolean }): Promise<void> => {
496
  const prompt = getActivePromptValue();
497
  if (askInFlight || prompt.length === 0) return;
client/src/partials/settings-toolbar-controls.html ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ <span class="online-count" aria-label="Online users">
2
+ <span class="online-count-label">Online:</span>
3
+ <span id="online_count_value" class="online-count-value" aria-live="polite">—</span>
4
+ </span>
5
+ <button id="settings_btn" class="settings-btn" title="Settings" data-i18n="title">
6
+ <span class="settings-icon">⚙️</span>
7
+ </button>
client/src/shared/api/GLTR_API.ts CHANGED
@@ -235,9 +235,6 @@ export class TextAnalysisAPI {
235
  });
236
  }
237
 
238
- /**
239
- * 获取访问统计(管理员)
240
- */
241
  public getVisitStats(): Promise<{
242
  success: boolean,
243
  totals: { page_loads: number, active_visits: number },
@@ -264,6 +261,8 @@ export class TextAnalysisAPI {
264
  gen_attr_opt_sec?: Record<string, number>,
265
  },
266
  reset_at?: string | null,
 
 
267
  }> {
268
  return d3.json(this.baseURL + '/api/visit_stats', {
269
  headers: this.getHeaders()
 
235
  });
236
  }
237
 
 
 
 
238
  public getVisitStats(): Promise<{
239
  success: boolean,
240
  totals: { page_loads: number, active_visits: number },
 
261
  gen_attr_opt_sec?: Record<string, number>,
262
  },
263
  reset_at?: string | null,
264
+ online_now?: number,
265
+ online_window_sec?: number,
266
  }> {
267
  return d3.json(this.baseURL + '/api/visit_stats', {
268
  headers: this.getHeaders()
client/src/shared/bootstrap.ts CHANGED
@@ -9,6 +9,7 @@ import { TextAnalysisAPI } from './api/GLTR_API';
9
  import { initForceNarrowFromStorage } from './core/responsive';
10
  import { getTokenSurprisalColor, getByteSurprisalColor, HISTOGRAM_MIN_ALPHA } from './cross/SurprisalColorConfig';
11
  import { initClientActivityPing } from './core/clientActivityPing';
 
12
 
13
  /**
14
  * 公共初始化返回对象
@@ -29,15 +30,18 @@ export interface CommonAppContext {
29
  */
30
  export function initializeCommonApp(apiPrefix: string = '', element?: Element): CommonAppContext {
31
  initForceNarrowFromStorage();
 
32
  initClientActivityPing(apiPrefix);
33
 
 
 
34
  // 使用传入的元素或默认 body 元素
35
  const targetElement = element || document.body;
36
 
37
  const format = d3.format('.2f');
38
  return {
39
  eventHandler: new SimpleEventHandler(targetElement),
40
- api: new TextAnalysisAPI(apiPrefix),
41
  tokenSurprisalColorScale: (v) => getTokenSurprisalColor(v, HISTOGRAM_MIN_ALPHA),
42
  byteSurprisalColorScale: (v) => getByteSurprisalColor(v, 1, HISTOGRAM_MIN_ALPHA),
43
  totalSurprisalFormat: (n: number | null) => n !== null && Number.isFinite(n) ? format(n) : String(n)
 
9
  import { initForceNarrowFromStorage } from './core/responsive';
10
  import { getTokenSurprisalColor, getByteSurprisalColor, HISTOGRAM_MIN_ALPHA } from './cross/SurprisalColorConfig';
11
  import { initClientActivityPing } from './core/clientActivityPing';
12
+ import { initOnlineCountDisplay } from './cross/onlineCountDisplay';
13
 
14
  /**
15
  * 公共初始化返回对象
 
30
  */
31
  export function initializeCommonApp(apiPrefix: string = '', element?: Element): CommonAppContext {
32
  initForceNarrowFromStorage();
33
+ initOnlineCountDisplay();
34
  initClientActivityPing(apiPrefix);
35
 
36
+ const api = new TextAnalysisAPI(apiPrefix);
37
+
38
  // 使用传入的元素或默认 body 元素
39
  const targetElement = element || document.body;
40
 
41
  const format = d3.format('.2f');
42
  return {
43
  eventHandler: new SimpleEventHandler(targetElement),
44
+ api,
45
  tokenSurprisalColorScale: (v) => getTokenSurprisalColor(v, HISTOGRAM_MIN_ALPHA),
46
  byteSurprisalColorScale: (v) => getByteSurprisalColor(v, 1, HISTOGRAM_MIN_ALPHA),
47
  totalSurprisalFormat: (n: number | null) => n !== null && Number.isFinite(n) ? format(n) : String(n)
client/src/shared/core/activitySession.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 各页入口注册 isBusy;心跳 tick 用 isSessionActive() = 可见 || getter()。
3
+ */
4
+
5
+ let pageBusyGetter: (() => boolean) | undefined;
6
+
7
+ export function registerPageBusy(getter: () => boolean): void {
8
+ pageBusyGetter = getter;
9
+ }
10
+
11
+ export function isSessionActive(): boolean {
12
+ if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
13
+ return true;
14
+ }
15
+ return pageBusyGetter?.() ?? false;
16
+ }
client/src/shared/core/clientActivityPing.ts CHANGED
@@ -1,7 +1,13 @@
1
  import URLHandler from './URLHandler';
2
  import { AdminManager } from '../cross/adminManager';
 
 
3
 
4
- const S = 10, FIRST = 2;
 
 
 
 
5
 
6
  let _pageOptsGetter: (() => Record<string, boolean>) | undefined;
7
  /** gen_attribute.html 等页面注册当前选项状态,供心跳上报时附带。 */
@@ -11,7 +17,7 @@ export function setPageOptsGetter(fn: () => Record<string, boolean>): void {
11
 
12
  export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
13
 
14
- /** UA 粗略归类;仅在首轮心跳(cum === FIRST)顺带上报一次。 */
15
  function detectInitialClientOs(): ReportedClientOs {
16
  const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
17
  if (/iPad|iPhone|iPod/i.test(ua)) return 'ios';
@@ -27,37 +33,64 @@ function detectInitialClientOs(): ReportedClientOs {
27
  return 'unknown';
28
  }
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  export function initClientActivityPing(apiPrefix: string | null | undefined): void {
31
  if (typeof window === 'undefined') return;
32
- if (AdminManager.getInstance().isInAdminMode()) return;
33
- const u = `${apiPrefix || URLHandler.basicURL()}/api/client-activity`;
34
- let n = 0, reportedTotal = 0, id: number | undefined;
35
- const vis = () => document.visibilityState === 'visible';
36
- const tick = () => {
37
- if (!vis()) return;
38
- n += 1;
39
- if (n !== FIRST && n % S !== 0) return;
40
- const cum = n;
41
- const delta_active_sec = Math.max(cum - reportedTotal, 0);
42
  const payload: Record<string, unknown> = {
43
  page_path: location.pathname + location.search,
44
- total_active_sec: cum,
45
- delta_active_sec,
46
  };
47
- if (cum === FIRST) payload.client_os = detectInitialClientOs();
48
  if (_pageOptsGetter) {
49
  const active = Object.fromEntries(Object.entries(_pageOptsGetter()).filter(([, v]) => v));
50
  if (Object.keys(active).length > 0) payload.page_opts = active;
51
  }
52
- const body = JSON.stringify(payload);
53
- void fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
54
- .then((r) => { if (r.ok) reportedTotal = cum; })
 
 
 
 
 
 
 
 
 
 
55
  .catch(() => {});
56
  };
57
- const sync = () => {
58
- if (id !== undefined) { clearInterval(id); id = undefined; }
59
- if (vis()) id = window.setInterval(tick, 1000);
 
 
 
60
  };
61
- document.addEventListener('visibilitychange', sync);
62
- sync();
63
  }
 
1
  import URLHandler from './URLHandler';
2
  import { AdminManager } from '../cross/adminManager';
3
+ import { applyOnlineCount } from '../cross/onlineCountDisplay';
4
+ import { isSessionActive } from './activitySession';
5
 
6
+ /** 活跃秒采样间隔 */
7
+ const TICK_MS = 1000;
8
+ /** 上报档位:累计活跃秒达到这些值时 POST */
9
+ const FIRST_REPORT_SEC = 2;
10
+ const REPORT_INTERVAL_SEC = 10;
11
 
12
  let _pageOptsGetter: (() => Record<string, boolean>) | undefined;
13
  /** gen_attribute.html 等页面注册当前选项状态,供心跳上报时附带。 */
 
17
 
18
  export type ReportedClientOs = 'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown';
19
 
20
+ /** UA 粗略归类;仅在首轮心跳顺带上报一次。 */
21
  function detectInitialClientOs(): ReportedClientOs {
22
  const ua = typeof navigator !== 'undefined' ? navigator.userAgent || '' : '';
23
  if (/iPad|iPhone|iPod/i.test(ua)) return 'ios';
 
33
  return 'unknown';
34
  }
35
 
36
+ function applyOnlineFromActivityResponse(r: Response): void {
37
+ const ct = r.headers.get('content-type') || '';
38
+ if (!ct.includes('application/json')) return;
39
+ void r
40
+ .clone()
41
+ .json()
42
+ .then((data: { online_now?: unknown }) => {
43
+ if (data && 'online_now' in data) applyOnlineCount(data.online_now);
44
+ })
45
+ .catch(() => {});
46
+ }
47
+
48
+ /** 累计活跃秒为 2、10、20… 时上报 */
49
+ function shouldReportAtSec(totalActiveSec: number): boolean {
50
+ return totalActiveSec === FIRST_REPORT_SEC || totalActiveSec % REPORT_INTERVAL_SEC === 0;
51
+ }
52
+
53
  export function initClientActivityPing(apiPrefix: string | null | undefined): void {
54
  if (typeof window === 'undefined') return;
55
+ const admin = AdminManager.getInstance();
56
+ const endpoint = `${apiPrefix || URLHandler.basicURL()}/api/client-activity`;
57
+ let totalActiveSec = 0;
58
+ let lastReportedSec = 0;
59
+
60
+ const postActivity = (includeClientOs: boolean) => {
61
+ const deltaActiveSec = Math.max(totalActiveSec - lastReportedSec, 0);
 
 
 
62
  const payload: Record<string, unknown> = {
63
  page_path: location.pathname + location.search,
64
+ total_active_sec: totalActiveSec,
65
+ delta_active_sec: deltaActiveSec,
66
  };
67
+ if (includeClientOs) payload.client_os = detectInitialClientOs();
68
  if (_pageOptsGetter) {
69
  const active = Object.fromEntries(Object.entries(_pageOptsGetter()).filter(([, v]) => v));
70
  if (Object.keys(active).length > 0) payload.page_opts = active;
71
  }
72
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
73
+ const adminToken = admin.isInAdminMode() ? admin.getAdminToken() : null;
74
+ if (adminToken) headers['X-Admin-Token'] = adminToken;
75
+ void fetch(endpoint, {
76
+ method: 'POST',
77
+ headers,
78
+ body: JSON.stringify(payload),
79
+ keepalive: true,
80
+ })
81
+ .then((r) => {
82
+ applyOnlineFromActivityResponse(r);
83
+ if (r.ok) lastReportedSec = totalActiveSec;
84
+ })
85
  .catch(() => {});
86
  };
87
+
88
+ const tick = () => {
89
+ if (!isSessionActive()) return;
90
+ totalActiveSec += 1;
91
+ if (!shouldReportAtSec(totalActiveSec)) return;
92
+ postActivity(totalActiveSec === FIRST_REPORT_SEC);
93
  };
94
+
95
+ window.setInterval(tick, TICK_MS);
96
  }
client/src/shared/cross/onlineCountDisplay.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * 页头在线人数(由 client-activity 响应更新 #online_count_value)
3
+ */
4
+
5
+ const VALUE_ID = 'online_count_value';
6
+
7
+ export function initOnlineCountDisplay(): void {
8
+ const el = document.getElementById(VALUE_ID);
9
+ if (el && !el.textContent?.trim()) el.textContent = '—';
10
+ }
11
+
12
+ export function applyOnlineCount(n: unknown): void {
13
+ const el = document.getElementById(VALUE_ID);
14
+ if (!el) return;
15
+ el.textContent =
16
+ typeof n === 'number' && Number.isFinite(n) ? String(n) : '—';
17
+ }
client/src/shared/cross/visitStatsDialog.ts CHANGED
@@ -130,6 +130,7 @@ function visitStatsHtml(data: VisitStatsRow): string {
130
  `[All-time (${g('+ delta since reset')})]`,
131
  `Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
132
  `Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
 
133
  '',
134
  '[OS]',
135
  ...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}),
 
130
  `[All-time (${g('+ delta since reset')})]`,
131
  `Page loads: ${fmtTotal(t.page_loads)}${deltaSuffix(t.page_loads - (sb.page_loads ?? 0))}`,
132
  `Active visits: ${fmtTotal(t.active_visits)}${deltaSuffix(t.active_visits - (sb.active_visits ?? 0))}`,
133
+ `Online: ${data.online_now ?? 'unknown'}`,
134
  '',
135
  '[OS]',
136
  ...linesJoined(orderedKeysGt0(OS_ORDER, os), os, sb.os ?? {}),
server.yaml CHANGED
@@ -397,14 +397,20 @@ paths:
397
  client_os:
398
  type: string
399
  description: 首轮心跳附带;ios / android / windows / macos / linux / unknown
 
 
400
  responses:
401
  200:
402
- description: acknowledged
403
  schema:
404
  type: object
405
  properties:
406
  ok:
407
  type: boolean
 
 
 
 
408
 
409
  /check_admin:
410
  post:
@@ -863,6 +869,13 @@ paths:
863
  type: string
864
  description: |
865
  最近一次手动 reset 的 UTC 时间;从未 reset 时为 null。
 
 
 
 
 
 
 
866
 
867
  /visit_stats/reset:
868
  post:
 
397
  client_os:
398
  type: string
399
  description: 首轮心跳附带;ios / android / windows / macos / linux / unknown
400
+ description: |
401
+ 有效心跳计入访问统计与在线估计。请求头带有效 X-Admin-Token 时仅返回 online_now,不计入统计。
402
  responses:
403
  200:
404
+ description: acknowledged; includes estimated online_now after valid heartbeat
405
  schema:
406
  type: object
407
  properties:
408
  ok:
409
  type: boolean
410
+ online_now:
411
+ type: integer
412
+ online_window_sec:
413
+ type: integer
414
 
415
  /check_admin:
416
  post:
 
869
  type: string
870
  description: |
871
  最近一次手动 reset 的 UTC 时间;从未 reset 时为 null。
872
+ online_now:
873
+ type: integer
874
+ description: |
875
+ 最近 online_window_sec 内 client-activity 心跳数按每 10s 一次折算的当前在线估计(ceil);进程内存,重启清零。
876
+ online_window_sec:
877
+ type: integer
878
+ description: 在线估计滑动窗口秒数(当前为 20)。
879
 
880
  /visit_stats/reset:
881
  post: