From bbdd5520ca85da4b6a81e324269c180b87fffd3b Mon Sep 17 00:00:00 2001 From: AeBoPi <109503402+AeBoPi@users.noreply.github.com> Date: Mon, 25 May 2026 11:29:15 +0800 Subject: [PATCH 1/5] feat: add admin analytics dashboard --- backend/api/routes/__init__.py | 2 + backend/api/routes/admin_analytics.py | 216 +++++++++++++++ backend/api/routes/auth.py | 9 +- backend/api/routes/config.py | 11 +- backend/api/routes/discover.py | 17 +- backend/api/routes/modes.py | 7 +- backend/api/routes/render.py | 15 ++ backend/api/routes/user.py | 21 +- backend/core/activity_store.py | 81 ++++++ backend/migrations/__init__.py | 27 ++ webapp/app/[locale]/admin/analytics/page.tsx | 1 + webapp/app/admin/analytics/page.tsx | 245 ++++++++++++++++++ .../app/api/admin/analytics/overview/route.ts | 6 + webapp/app/api/analytics/pageview/route.ts | 6 + webapp/app/layout.tsx | 5 + webapp/components/pageview-tracker.tsx | 33 +++ 16 files changed, 690 insertions(+), 12 deletions(-) create mode 100644 backend/api/routes/admin_analytics.py create mode 100644 backend/core/activity_store.py create mode 100644 webapp/app/[locale]/admin/analytics/page.tsx create mode 100644 webapp/app/admin/analytics/page.tsx create mode 100644 webapp/app/api/admin/analytics/overview/route.ts create mode 100644 webapp/app/api/analytics/pageview/route.ts create mode 100644 webapp/components/pageview-tracker.tsx diff --git a/backend/api/routes/__init__.py b/backend/api/routes/__init__.py index 9bce3d3..17a81eb 100644 --- a/backend/api/routes/__init__.py +++ b/backend/api/routes/__init__.py @@ -1,3 +1,4 @@ +from .admin_analytics import router as admin_analytics_router from .auth import router as auth_router from .config import router as config_router from .device import router as device_router @@ -16,6 +17,7 @@ api_routers = [ render_router, + admin_analytics_router, config_router, device_router, modes_router, diff --git a/backend/api/routes/admin_analytics.py b/backend/api/routes/admin_analytics.py new file mode 100644 index 0000000..5cbaa0e --- /dev/null +++ b/backend/api/routes/admin_analytics.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, Request + +from core.activity_store import log_user_activity +from core.auth import decode_session_token, get_current_root_user +from core.db import get_main_db + +router = APIRouter(tags=["admin-analytics"]) + + +def _optional_user_id(request: Request, ink_session: Optional[str]) -> int | None: + tokens = [] + if ink_session: + tokens.append(ink_session) + auth = request.headers.get("authorization", "") + if auth.startswith("Bearer "): + tokens.append(auth[7:]) + for token in tokens: + payload = decode_session_token(token) + if not payload or "sub" not in payload: + continue + try: + return int(payload["sub"]) + except (TypeError, ValueError): + continue + return None + + +@router.post("/analytics/pageview") +async def analytics_pageview( + body: dict, + request: Request, + ink_session: Optional[str] = Cookie(default=None), +): + await log_user_activity( + _optional_user_id(request, ink_session), + "page.view", + request=request, + source=str(body.get("source") or "webapp"), + path=str(body.get("path") or request.headers.get("referer") or "/"), + method="GET", + metadata={"mac": str(body.get("mac") or "")[:17]}, + ) + return {"ok": True} + + +async def _scalar(sql: str, params: tuple = ()) -> int | float: + db = await get_main_db() + cursor = await db.execute(sql, params) + row = await cursor.fetchone() + value = row[0] if row else 0 + return value or 0 + + +async def _rows(sql: str, params: tuple = ()) -> list[dict]: + db = await get_main_db() + cursor = await db.execute(sql, params) + columns = [item[0] for item in cursor.description] + return [dict(zip(columns, row)) for row in await cursor.fetchall()] + + +@router.get("/admin/analytics/overview") +async def admin_analytics_overview(_: int = Depends(get_current_root_user)): + """Root-only analytics summary for the operations dashboard.""" + return { + "users": { + "total": await _scalar("SELECT COUNT(*) FROM users"), + "today_new": await _scalar("SELECT COUNT(*) FROM users WHERE date(created_at)=date('now','localtime')"), + "new_7d": await _scalar("SELECT COUNT(*) FROM users WHERE created_at >= datetime('now','localtime','-7 days')"), + "new_30d": await _scalar("SELECT COUNT(*) FROM users WHERE created_at >= datetime('now','localtime','-30 days')"), + "with_device": await _scalar("SELECT COUNT(DISTINCT user_id) FROM device_memberships WHERE status='active'"), + "dau": await _scalar( + """ + SELECT COUNT(DISTINCT user_id) FROM user_activity_events + WHERE user_id IS NOT NULL + AND date(created_at)=date('now','localtime') + AND event_name != 'auth.register' + """ + ), + "wau": await _scalar( + """ + SELECT COUNT(DISTINCT user_id) FROM user_activity_events + WHERE user_id IS NOT NULL + AND created_at >= datetime('now','localtime','-7 days') + AND event_name != 'auth.register' + """ + ), + "mau": await _scalar( + """ + SELECT COUNT(DISTINCT user_id) FROM user_activity_events + WHERE user_id IS NOT NULL + AND created_at >= datetime('now','localtime','-30 days') + AND event_name != 'auth.register' + """ + ), + "device_active_24h": await _scalar( + """ + WITH active_devices AS ( + SELECT DISTINCT mac FROM device_heartbeats WHERE created_at >= datetime('now','localtime','-24 hours') + UNION + SELECT DISTINCT mac FROM render_logs WHERE created_at >= datetime('now','localtime','-24 hours') + ) + SELECT COUNT(DISTINCT dm.user_id) + FROM device_memberships dm + JOIN active_devices ad ON ad.mac = dm.mac + WHERE dm.status = 'active' + """ + ), + }, + "devices": { + "bound": await _scalar("SELECT COUNT(DISTINCT mac) FROM device_memberships WHERE status='active'"), + "active_today": await _scalar( + """ + WITH active_devices AS ( + SELECT DISTINCT mac FROM device_heartbeats WHERE date(created_at)=date('now','localtime') + UNION + SELECT DISTINCT mac FROM render_logs WHERE date(created_at)=date('now','localtime') + ) + SELECT COUNT(*) FROM active_devices + """ + ), + "active_7d": await _scalar( + """ + WITH active_devices AS ( + SELECT DISTINCT mac FROM device_heartbeats WHERE created_at >= datetime('now','localtime','-7 days') + UNION + SELECT DISTINCT mac FROM render_logs WHERE created_at >= datetime('now','localtime','-7 days') + ) + SELECT COUNT(*) FROM active_devices + """ + ), + "heartbeats_today": await _scalar("SELECT COUNT(*) FROM device_heartbeats WHERE date(created_at)=date('now','localtime')"), + }, + "rendering": { + "today": await _scalar("SELECT COUNT(*) FROM render_logs WHERE date(created_at)=date('now','localtime')"), + "last_7d": await _scalar("SELECT COUNT(*) FROM render_logs WHERE created_at >= datetime('now','localtime','-7 days')"), + "avg_ms_today": await _scalar( + "SELECT ROUND(AVG(render_time_ms), 0) FROM render_logs WHERE date(created_at)=date('now','localtime') AND status='success'" + ), + "errors_today": await _scalar("SELECT COUNT(*) FROM render_logs WHERE date(created_at)=date('now','localtime') AND status!='success'"), + "fallback_today": await _scalar("SELECT COUNT(*) FROM render_logs WHERE date(created_at)=date('now','localtime') AND is_fallback=1"), + }, + "content": { + "custom_modes": await _scalar("SELECT COUNT(*) FROM custom_modes"), + "shared_modes": await _scalar("SELECT COUNT(*) FROM shared_modes WHERE is_active=1"), + "users_with_llm_config": await _scalar("SELECT COUNT(*) FROM user_llm_config"), + }, + "series": { + "new_users": await _rows( + """ + SELECT date(created_at) AS day, COUNT(*) AS count + FROM users + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + "active_devices": await _rows( + """ + SELECT day, COUNT(DISTINCT mac) AS count + FROM ( + SELECT date(created_at) AS day, mac FROM device_heartbeats + UNION ALL + SELECT date(created_at) AS day, mac FROM render_logs + ) + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + "renders": await _rows( + """ + SELECT date(created_at) AS day, COUNT(*) AS count + FROM render_logs + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + "activity_events": await _rows( + """ + SELECT date(created_at) AS day, COUNT(DISTINCT user_id) AS active_users + FROM user_activity_events + WHERE user_id IS NOT NULL AND event_name != 'auth.register' + GROUP BY day + ORDER BY day DESC + LIMIT 14 + """ + ), + }, + "top": { + "events": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + WHERE created_at >= datetime('now','localtime','-7 days') + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + "modes": await _rows( + """ + SELECT persona AS mode, COUNT(*) AS count + FROM render_logs + WHERE created_at >= datetime('now','localtime','-7 days') + GROUP BY persona + ORDER BY count DESC + LIMIT 12 + """ + ), + }, + } diff --git a/backend/api/routes/auth.py b/backend/api/routes/auth.py index bd0ace1..80c00ef 100644 --- a/backend/api/routes/auth.py +++ b/backend/api/routes/auth.py @@ -5,10 +5,11 @@ import aiosqlite import phonenumbers -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import JSONResponse from phonenumbers.phonenumberutil import NumberParseException +from core.activity_store import log_user_activity from core.auth import clear_session_cookie, create_session_token, require_user, set_session_cookie from core.config_store import authenticate_user, _hash_password, get_user_api_quota from core.db import get_main_db @@ -62,7 +63,7 @@ async def _phone_exists(db, normalized_phone: str) -> bool: @router.post("/auth/register") -async def auth_register(body: dict, response: Response): +async def auth_register(body: dict, response: Response, request: Request = None): username = (body.get("username") or "").strip() password = body.get("password") or "" phone = (body.get("phone") or "").strip() @@ -133,11 +134,12 @@ async def auth_register(body: dict, response: Response): token = create_session_token(user_id, username) set_session_cookie(response, token) + await log_user_activity(user_id, "auth.register", request=request, metadata={"username": username}) return {"ok": True, "user_id": user_id, "username": username, "token": token} @router.post("/auth/login") -async def auth_login(body: dict, response: Response): +async def auth_login(body: dict, response: Response, request: Request = None): username = (body.get("username") or "").strip() password = body.get("password") or "" user = await authenticate_user(username, password) @@ -145,6 +147,7 @@ async def auth_login(body: dict, response: Response): return JSONResponse({"error": "用户名或密码错误"}, status_code=401) token = create_session_token(user["id"], user["username"]) set_session_cookie(response, token) + await log_user_activity(user["id"], "auth.login", request=request) return {"ok": True, "user_id": user["id"], "username": user["username"], "token": token} diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 0c74cff..bf421ac 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -6,6 +6,7 @@ from fastapi.responses import JSONResponse from api.shared import ensure_web_or_device_access, logger +from core.activity_store import log_user_activity from core.auth import is_admin_authorized, require_admin, validate_mac_param from core.config_store import ( activate_config, @@ -32,8 +33,9 @@ async def post_config( ): data = body.model_dump(by_alias=True) mac = data["mac"] + access = None if not is_admin_authorized(authorization): - await ensure_web_or_device_access( + access = await ensure_web_or_device_access( request, mac, x_device_token, @@ -50,6 +52,13 @@ async def post_config( ) config_id = await save_config(mac, data) await set_pending_refresh(mac, True) + if access and access.get("mode") == "user": + await log_user_activity( + int(access["user_id"]), + "config.save", + request=request, + metadata={"mac": mac, "modes": len(modes) if isinstance(modes, list) else 0}, + ) saved_config = await get_active_config(mac) if saved_config: diff --git a/backend/api/routes/discover.py b/backend/api/routes/discover.py index 39fa355..9abe562 100644 --- a/backend/api/routes/discover.py +++ b/backend/api/routes/discover.py @@ -9,10 +9,11 @@ from pathlib import Path from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import JSONResponse from api.shared import logger +from core.activity_store import log_user_activity from core.auth import require_user from core.config import SCREEN_HEIGHT, SCREEN_WIDTH from core.config_store import get_main_db @@ -99,6 +100,7 @@ async def list_shared_modes( @router.post("/discover/modes/publish") async def publish_mode( body: dict, + request: Request, user_id: int = Depends(require_user), ): """发布模式到广场(需要认证)""" @@ -329,6 +331,12 @@ async def publish_mode( shared_mode_id = cursor.lastrowid logger.info(f"[DISCOVER] User {user_id} published mode {source_custom_mode_id} as shared mode {shared_mode_id}") + await log_user_activity( + user_id, + "mode.publish", + request=request, + metadata={"mode_id": source_custom_mode_id, "shared_mode_id": shared_mode_id, "category": category, "mac": mac}, + ) return {"ok": True, "id": shared_mode_id} @@ -336,6 +344,7 @@ async def publish_mode( async def install_shared_mode( mode_id: int, body: dict, + request: Request, user_id: int = Depends(require_user), ): """安装共享模式到用户本地设备(需要认证)""" @@ -406,4 +415,10 @@ async def install_shared_mode( return JSONResponse({"error": "模式加载失败"}, status_code=500) logger.info(f"[DISCOVER] User {user_id} installed shared mode {mode_id} as {new_mode_id} on device {mac}") + await log_user_activity( + user_id, + "mode.install", + request=request, + metadata={"shared_mode_id": mode_id, "mode_id": new_mode_id, "mac": mac}, + ) return {"ok": True, "custom_mode_id": new_mode_id} diff --git a/backend/api/routes/modes.py b/backend/api/routes/modes.py index f1ed3fe..18b8ffa 100644 --- a/backend/api/routes/modes.py +++ b/backend/api/routes/modes.py @@ -4,11 +4,12 @@ import json as jsonlib from pathlib import Path import os -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import JSONResponse, StreamingResponse from openai import OpenAIError from api.shared import logger +from core.activity_store import log_user_activity from core.auth import require_user, optional_user from core.config import SCREEN_HEIGHT, SCREEN_WIDTH, get_default_llm_model_for_provider from core.config_store import remove_mode_from_all_configs @@ -553,7 +554,7 @@ async def custom_mode_preview( @router.post("/modes/custom") -async def create_custom_mode(body: dict, user_id: int = Depends(require_user)): +async def create_custom_mode(body: dict, request: Request, user_id: int = Depends(require_user)): """Create a custom mode. - With `mac`: persist to DB with user+device isolation. @@ -585,6 +586,7 @@ async def create_custom_mode(body: dict, user_id: int = Depends(require_user)): file_path.unlink(missing_ok=True) return JSONResponse({"error": "Failed to load mode definition"}, status_code=400) logger.info("[MODES] Created legacy custom mode %s for user %s", mode_id, user_id) + await log_user_activity(user_id, "mode.custom.create", request=request, metadata={"mode_id": mode_id, "legacy": True}) return {"ok": True, "mode_id": mode_id} # Validate device ownership for DB path @@ -609,6 +611,7 @@ async def create_custom_mode(body: dict, user_id: int = Depends(require_user)): return JSONResponse({"error": "Failed to load mode definition"}, status_code=400) logger.info(f"[MODES] Created custom mode {mode_id} for user {user_id} on device {mac}") + await log_user_activity(user_id, "mode.custom.create", request=request, metadata={"mode_id": mode_id, "mac": mac}) return {"ok": True, "mode_id": mode_id} diff --git a/backend/api/routes/render.py b/backend/api/routes/render.py index e188b94..6c9dd0e 100644 --- a/backend/api/routes/render.py +++ b/backend/api/routes/render.py @@ -24,6 +24,7 @@ resolve_preview_voltage, resolve_refresh_minutes_for_device_state, ) +from core.activity_store import log_user_activity from core.auth import require_device_token, validate_mac_param from core.config import DEFAULT_REFRESH_INTERVAL, SCREEN_HEIGHT, SCREEN_WIDTH from core.config_store import ( @@ -368,6 +369,13 @@ async def preview( ) if intent == 1: from fastapi.responses import JSONResponse + if current_user_id: + await log_user_activity( + current_user_id, + "preview.intent", + request=request, + metadata={"mac": mac or "", "persona": resolved_persona, "cache_hit": bool(cache_hit)}, + ) return JSONResponse( status_code=200, @@ -410,6 +418,13 @@ async def preview( ) png_bytes = image_to_png_bytes(img) logger.info("[PREVIEW] Generated PNG persona=%s size=%sx%s", resolved_persona, w, h) + if current_user_id: + await log_user_activity( + current_user_id, + "preview.render", + request=request, + metadata={"mac": mac or "", "persona": resolved_persona, "cache_hit": bool(cache_hit), "size": f"{w}x{h}"}, + ) # 确定生成状态(使用英文避免编码问题) status_msg = "no_llm_required" if not llm_mode_requires_quota else ("model_generated" if not _content_fallback else "fallback_used") diff --git a/backend/api/routes/user.py b/backend/api/routes/user.py index 4d298d6..e191b9a 100644 --- a/backend/api/routes/user.py +++ b/backend/api/routes/user.py @@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse from api.shared import require_membership_access +from core.activity_store import log_user_activity from core.auth import require_user, validate_mac_param from core.config_store import ( approve_access_request, @@ -40,12 +41,14 @@ async def list_user_devices(user_id: int = Depends(require_user)): @router.post("/user/devices") -async def bind_user_device(body: dict, user_id: int = Depends(require_user)): +async def bind_user_device(body: dict, request: Request, user_id: int = Depends(require_user)): mac = validate_mac_param((body.get("mac") or "").strip().upper()) nickname = (body.get("nickname") or "").strip() if not mac: return JSONResponse({"error": "MAC 地址不能为空"}, status_code=400) - return {"ok": True, **await bind_device(user_id, mac, nickname)} + result = await bind_device(user_id, mac, nickname) + await log_user_activity(user_id, "device.bind", request=request, metadata={"mac": mac, "role": result.get("role")}) + return {"ok": True, **result} @router.delete("/user/devices/{mac}") @@ -129,7 +132,7 @@ def _mask_key(key: str) -> str: @router.get("/user/profile") -async def get_user_profile(user_id: int = Depends(require_user)): +async def get_user_profile(request: Request, user_id: int = Depends(require_user)): db = await get_main_db() cursor = await db.execute( @@ -139,6 +142,7 @@ async def get_user_profile(user_id: int = Depends(require_user)): user_row = await cursor.fetchone() if not user_row: return JSONResponse({"error": "用户不存在"}, status_code=404) + await log_user_activity(user_id, "profile.open", request=request) quota = await get_user_api_quota(user_id) @@ -160,7 +164,7 @@ async def get_user_profile(user_id: int = Depends(require_user)): @router.put("/user/profile/llm") -async def save_user_llm_config_route(body: dict, user_id: int = Depends(require_user)): +async def save_user_llm_config_route(body: dict, request: Request, user_id: int = Depends(require_user)): """保存用户级别的 LLM 配置。""" llm_access_mode = (body.get("llm_access_mode") or "preset").strip().lower() provider = (body.get("provider") or "deepseek").strip() @@ -199,14 +203,21 @@ async def save_user_llm_config_route(body: dict, user_id: int = Depends(require_ ) if not ok: return JSONResponse({"error": "保存配置失败"}, status_code=500) + await log_user_activity( + user_id, + "profile.llm_config.save", + request=request, + metadata={"llm_access_mode": llm_access_mode, "provider": provider, "image_provider": image_provider}, + ) return {"ok": True, "message": "配置已保存"} @router.delete("/user/profile/llm") -async def delete_user_llm_config_route(user_id: int = Depends(require_user)): +async def delete_user_llm_config_route(request: Request, user_id: int = Depends(require_user)): """删除用户级别的 LLM 配置(BYOK)。""" deleted = await delete_user_llm_config(user_id) + await log_user_activity(user_id, "profile.llm_config.delete", request=request, metadata={"deleted": bool(deleted)}) # 幂等:即使本来就没有配置,也返回 ok,避免前端交互分叉 return {"ok": True, "deleted": bool(deleted), "message": "配置已删除"} diff --git a/backend/core/activity_store.py b/backend/core/activity_store.py new file mode 100644 index 0000000..b50176c --- /dev/null +++ b/backend/core/activity_store.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import hashlib +import json +import logging +from datetime import datetime +from typing import Any + +from fastapi import Request + +from .db import get_main_db + +logger = logging.getLogger(__name__) + + +def _client_ip(request: Request | None) -> str: + if request is None: + return "" + forwarded = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip() + if forwarded: + return forwarded + if request.client: + return request.client.host or "" + return "" + + +def _hash_ip(ip: str) -> str: + if not ip: + return "" + return hashlib.sha256(ip.encode("utf-8")).hexdigest()[:24] + + +def _json_safe(metadata: dict[str, Any] | None) -> str: + if not metadata: + return "{}" + try: + return json.dumps(metadata, ensure_ascii=False, sort_keys=True, default=str) + except (TypeError, ValueError): + return "{}" + + +async def log_user_activity( + user_id: int | None, + event_name: str, + *, + request: Request | None = None, + source: str = "web", + path: str = "", + method: str = "", + metadata: dict[str, Any] | None = None, +) -> None: + """Best-effort activity logging; never break the user-facing request.""" + event = (event_name or "").strip() + if not event: + return + try: + db = await get_main_db() + req_path = path or (str(request.url.path) if request else "") + req_method = method or (request.method if request else "") + user_agent = (request.headers.get("user-agent") or "")[:300] if request else "" + await db.execute( + """ + INSERT INTO user_activity_events + (user_id, event_name, source, path, method, ip_hash, user_agent, metadata_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + event, + source, + req_path[:300], + req_method[:16], + _hash_ip(_client_ip(request)), + user_agent, + _json_safe(metadata), + datetime.now().isoformat(), + ), + ) + await db.commit() + except Exception: + logger.warning("[ACTIVITY] Failed to log event=%s user_id=%s", event, user_id, exc_info=True) diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py index 6dcb28e..f3d64a2 100644 --- a/backend/migrations/__init__.py +++ b/backend/migrations/__init__.py @@ -89,6 +89,33 @@ async def run_main_db_migrations(db, *, defaults: dict[str, str]) -> None: (21, "configs.admin1", lambda: _add_column_if_missing(db, "configs", "admin1", "admin1 TEXT DEFAULT ''")), (22, "configs.country", lambda: _add_column_if_missing(db, "configs", "country", "country TEXT DEFAULT ''")), (23, "device_state.ota_original_url", lambda: _add_column_if_missing(db, "device_state", "ota_original_url", "ota_original_url TEXT DEFAULT ''")), + ( + 24, + "user_activity_events.create", + lambda: db.executescript( + """ + CREATE TABLE IF NOT EXISTS user_activity_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + event_name TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'web', + path TEXT DEFAULT '', + method TEXT DEFAULT '', + ip_hash TEXT DEFAULT '', + user_agent TEXT DEFAULT '', + metadata_json TEXT DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE INDEX IF NOT EXISTS idx_user_activity_user_time + ON user_activity_events(user_id, created_at); + CREATE INDEX IF NOT EXISTS idx_user_activity_event_time + ON user_activity_events(event_name, created_at); + CREATE INDEX IF NOT EXISTS idx_user_activity_time + ON user_activity_events(created_at); + """ + ), + ), ] now = datetime.now().isoformat() diff --git a/webapp/app/[locale]/admin/analytics/page.tsx b/webapp/app/[locale]/admin/analytics/page.tsx new file mode 100644 index 0000000..69f050d --- /dev/null +++ b/webapp/app/[locale]/admin/analytics/page.tsx @@ -0,0 +1 @@ +export { default } from "../../../admin/analytics/page"; diff --git a/webapp/app/admin/analytics/page.tsx b/webapp/app/admin/analytics/page.tsx new file mode 100644 index 0000000..05cf4fe --- /dev/null +++ b/webapp/app/admin/analytics/page.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import Link from "next/link"; +import { BarChart3, Cpu, Loader2, MonitorSmartphone, Users, WandSparkles } from "lucide-react"; +import { authHeaders } from "@/lib/auth"; + +type CountRow = { day: string; count?: number; active_users?: number }; +type EventRow = { event_name: string; count: number }; +type ModeRow = { mode: string; count: number }; + +type AnalyticsOverview = { + users: { + total: number; + today_new: number; + new_7d: number; + new_30d: number; + with_device: number; + dau: number; + wau: number; + mau: number; + device_active_24h: number; + }; + devices: { + bound: number; + active_today: number; + active_7d: number; + heartbeats_today: number; + }; + rendering: { + today: number; + last_7d: number; + avg_ms_today: number; + errors_today: number; + fallback_today: number; + }; + content: { + custom_modes: number; + shared_modes: number; + users_with_llm_config: number; + }; + series: { + new_users: CountRow[]; + active_devices: CountRow[]; + renders: CountRow[]; + activity_events: CountRow[]; + }; + top: { + events: EventRow[]; + modes: ModeRow[]; + }; +}; + +function fmt(value: number | null | undefined) { + return Number(value || 0).toLocaleString(); +} + +function MetricCard({ + title, + value, + note, + icon, +}: { + title: string; + value: number; + note: string; + icon: ReactNode; +}) { + return ( +
+
+

{title}

+
{icon}
+
+

{fmt(value)}

+

{note}

+
+ ); +} + +function MiniBars({ rows, valueKey = "count" }: { rows: CountRow[]; valueKey?: "count" | "active_users" }) { + const ordered = [...rows].reverse(); + const max = Math.max(...ordered.map((row) => Number(row[valueKey] || 0)), 1); + return ( +
+ {ordered.map((row) => { + const value = Number(row[valueKey] || 0); + return ( +
+
+ {row.day.slice(5)} +
+ ); + })} +
+ ); +} + +function Ranking({ rows, nameKey }: { rows: Array; nameKey: "event_name" | "mode" }) { + if (!rows.length) return

暂无数据,部署后会逐步积累。

; + const max = Math.max(...rows.map((row) => row.count), 1); + return ( +
+ {rows.map((row) => { + const label = nameKey === "event_name" ? (row as EventRow).event_name : (row as ModeRow).mode; + return ( +
+
+ {label} + {fmt(row.count)} +
+
+
+
+
+ ); + })} +
+ ); +} + +export default function AdminAnalyticsPage() { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/admin/analytics/overview", { + cache: "no-store", + headers: authHeaders(), + }); + if (res.status === 401) throw new Error("请先登录 root 账号。"); + if (res.status === 403) throw new Error("当前账号没有 root 权限。"); + if (!res.ok) throw new Error(`加载失败:HTTP ${res.status}`); + const payload = (await res.json()) as AnalyticsOverview; + if (!cancelled) setData(payload); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : "加载失败"); + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+
+
+

InkSight Admin

+

运营看板

+

+ 统计注册、活跃用户、设备在线、渲染量、页面访问和内容创作。DAU/WAU/MAU 从新埋点启用后开始累积。 +

+
+ + 返回个人中心 + +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : data ? ( +
+
+ } /> + } /> + } /> + } /> +
+ +
+
+

14 日新增用户

+ +
+
+

内容创作

+
+

{fmt(data.content.custom_modes)}

自定义

+

{fmt(data.content.shared_modes)}

共享

+

{fmt(data.content.users_with_llm_config)}

BYOK

+
+

+ 设备反推活跃用户 24h:{fmt(data.users.device_active_24h)}。这可和 DAU 对比,判断“只设备在线但未网页登录”的用户规模。 +

+
+
+ +
+
+

7 日热门模式

+ +
+
+

7 日用户事件

+ +
+
+ +
+
+

14 日活跃设备

+ +
+
+

14 日渲染量

+ +
+
+ +
+
+ +

统计口径

+
+

+ 注册数来自 users;DAU/WAU/MAU 来自 user_activity_events;设备活跃来自心跳和渲染日志;渲染量来自 render_logs。 + 页面访问事件从本版本部署后开始写入,历史官网访问仍建议用 Nginx 独立 access log 回溯。 +

+
+
+ ) : null} +
+
+ ); +} diff --git a/webapp/app/api/admin/analytics/overview/route.ts b/webapp/app/api/admin/analytics/overview/route.ts new file mode 100644 index 0000000..869aa99 --- /dev/null +++ b/webapp/app/api/admin/analytics/overview/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyGet } from "../../../_proxy"; + +export async function GET(req: NextRequest) { + return proxyGet("/api/admin/analytics/overview", req); +} diff --git a/webapp/app/api/analytics/pageview/route.ts b/webapp/app/api/analytics/pageview/route.ts new file mode 100644 index 0000000..6afe0c6 --- /dev/null +++ b/webapp/app/api/analytics/pageview/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { proxyPost } from "../../_proxy"; + +export async function POST(req: NextRequest) { + return proxyPost("/api/analytics/pageview", req); +} diff --git a/webapp/app/layout.tsx b/webapp/app/layout.tsx index 65c1904..9361d3f 100644 --- a/webapp/app/layout.tsx +++ b/webapp/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { Inter, Noto_Serif_SC } from "next/font/google"; import { Navbar } from "@/components/navbar"; import { Footer } from "@/components/footer"; +import { PageviewTracker } from "@/components/pageview-tracker"; import { t } from "@/lib/i18n"; import { localeForRequest } from "@/lib/locale-server"; import "./globals.css"; @@ -49,6 +51,9 @@ export default async function RootLayout({ return ( + + +
{children}