终端 header 显示当前在线人数
- src/utils/online_tracker.py:5 分钟滑动窗口,thread-safe 内存追踪
- web/core.py:认证成功时 record_activity(user_id)
- web/routers/ops.py:GET /api/ops/online-users 返回 {online: N}
- 前端 header 每 60s 轮询,Users 图标 + 数字
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Table2,
|
Table2,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||||
@@ -388,6 +389,19 @@ function PolyWeatherTerminal({
|
|||||||
}, [searchInputRef, setSearchQuery]);
|
}, [searchInputRef, setSearchQuery]);
|
||||||
const [navExpanded, setNavExpanded] = useState(false);
|
const [navExpanded, setNavExpanded] = useState(false);
|
||||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||||
|
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchOnline = () => {
|
||||||
|
fetch("/api/ops/online-users", { headers: { Accept: "application/json" } })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((d) => { if (d?.online != null) setOnlineCount(d.online); })
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
fetchOnline();
|
||||||
|
const id = setInterval(fetchOnline, 60_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [gridCols, setGridCols] = useState<number>(() => {
|
const [gridCols, setGridCols] = useState<number>(() => {
|
||||||
return getStoredGridSide("polyweather_terminal_grid_cols");
|
return getStoredGridSide("polyweather_terminal_grid_cols");
|
||||||
@@ -659,6 +673,12 @@ function PolyWeatherTerminal({
|
|||||||
<Activity size={13} />
|
<Activity size={13} />
|
||||||
{t("dashboard", isEn)}
|
{t("dashboard", isEn)}
|
||||||
</div>
|
</div>
|
||||||
|
{onlineCount != null && (
|
||||||
|
<div className="hidden items-center gap-1 text-[10px] font-medium text-slate-400 lg:flex">
|
||||||
|
<Users size={12} />
|
||||||
|
<span>{onlineCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
<span className="hidden font-mono md:inline text-slate-500">{userLocalTime}</span>
|
<span className="hidden font-mono md:inline text-slate-500">{userLocalTime}</span>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Lightweight online-user tracker. Records last-seen timestamps in memory
|
||||||
|
and exposes a count of users active within a sliding window. No external
|
||||||
|
dependency — just a dict + lock + periodic cleanup thread."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
_online_users: Dict[str, float] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cleanup_interval_sec = 120
|
||||||
|
_window_sec = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_stale() -> None:
|
||||||
|
cutoff = time.time() - _window_sec
|
||||||
|
with _lock:
|
||||||
|
stale = [uid for uid, ts in _online_users.items() if ts < cutoff]
|
||||||
|
for uid in stale:
|
||||||
|
del _online_users[uid]
|
||||||
|
|
||||||
|
|
||||||
|
def _start_cleanup() -> None:
|
||||||
|
_cleanup_stale()
|
||||||
|
threading.Timer(_cleanup_interval_sec, _start_cleanup).start()
|
||||||
|
|
||||||
|
|
||||||
|
_start_cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def record_activity(user_id: str) -> None:
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
_online_users[user_id] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def online_count() -> int:
|
||||||
|
_cleanup_stale()
|
||||||
|
with _lock:
|
||||||
|
return len(_online_users)
|
||||||
@@ -230,6 +230,8 @@ def _bind_optional_supabase_identity(request: Request) -> None:
|
|||||||
request.state.auth_email = identity.email
|
request.state.auth_email = identity.email
|
||||||
request.state.auth_points = identity.points
|
request.state.auth_points = identity.points
|
||||||
request.state.auth_created_at = identity.created_at
|
request.state.auth_created_at = identity.created_at
|
||||||
|
from src.utils.online_tracker import record_activity
|
||||||
|
record_activity(identity.user_id)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_auth_points(request: Request) -> int:
|
def _resolve_auth_points(request: Request) -> int:
|
||||||
@@ -306,6 +308,8 @@ def _assert_entitlement(request: Request) -> None:
|
|||||||
|
|
||||||
request.state.auth_user_id = identity.user_id
|
request.state.auth_user_id = identity.user_id
|
||||||
request.state.auth_email = identity.email
|
request.state.auth_email = identity.email
|
||||||
|
from src.utils.online_tracker import record_activity
|
||||||
|
record_activity(identity.user_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _ENTITLEMENT_GUARD_ENABLED:
|
if not _ENTITLEMENT_GUARD_ENABLED:
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ from web.services.ops_api import (
|
|||||||
router = APIRouter(tags=["ops"])
|
router = APIRouter(tags=["ops"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/ops/online-users")
|
||||||
|
async def ops_online_users():
|
||||||
|
from src.utils.online_tracker import online_count
|
||||||
|
return {"online": online_count()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ops/users")
|
@router.get("/api/ops/users")
|
||||||
async def ops_search_users(request: Request, q: str = "", limit: int = 20):
|
async def ops_search_users(request: Request, q: str = "", limit: int = 20):
|
||||||
return search_ops_users(request, q=q, limit=limit)
|
return search_ops_users(request, q=q, limit=limit)
|
||||||
|
|||||||
Reference in New Issue
Block a user