diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 875d1e2f..81087fc6 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -12,6 +12,7 @@ import { Search, Table2, UserRound, + Users, } from "lucide-react"; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types"; @@ -388,6 +389,19 @@ function PolyWeatherTerminal({ }, [searchInputRef, setSearchQuery]); const [navExpanded, setNavExpanded] = useState(false); const [activeNavKey, setActiveNavKey] = useState("thresholds"); + const [onlineCount, setOnlineCount] = useState(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(() => { return getStoredGridSide("polyweather_terminal_grid_cols"); @@ -659,6 +673,12 @@ function PolyWeatherTerminal({ {t("dashboard", isEn)} + {onlineCount != null && ( +
+ + {onlineCount} +
+ )}
{userLocalTime} diff --git a/src/utils/online_tracker.py b/src/utils/online_tracker.py new file mode 100644 index 00000000..749bd33e --- /dev/null +++ b/src/utils/online_tracker.py @@ -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) diff --git a/web/core.py b/web/core.py index 94ad2f73..52a9f545 100644 --- a/web/core.py +++ b/web/core.py @@ -230,6 +230,8 @@ def _bind_optional_supabase_identity(request: Request) -> None: request.state.auth_email = identity.email request.state.auth_points = identity.points 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: @@ -306,6 +308,8 @@ def _assert_entitlement(request: Request) -> None: request.state.auth_user_id = identity.user_id request.state.auth_email = identity.email + from src.utils.online_tracker import record_activity + record_activity(identity.user_id) return if not _ENTITLEMENT_GUARD_ENABLED: diff --git a/web/routers/ops.py b/web/routers/ops.py index dfc74f42..4313d887 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -29,6 +29,12 @@ from web.services.ops_api import ( 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") async def ops_search_users(request: Request, q: str = "", limit: int = 20): return search_ops_users(request, q=q, limit=limit)