diff --git a/backend/api/index.py b/backend/api/index.py index b15d9bf..ef73a3f 100644 --- a/backend/api/index.py +++ b/backend/api/index.py @@ -104,6 +104,12 @@ async def dispatch(self, request: Request, call_next): return JSONResponse({"error": "origin_not_allowed"}, status_code=403) normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}" + forwarded_host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc + forwarded_proto = request.headers.get("x-forwarded-proto") or request.url.scheme + same_origin = f"{forwarded_proto.lower()}://{forwarded_host.lower()}" if forwarded_host else "" + if normalized == same_origin: + return await call_next(request) + if normalized in self.allow_origins: return await call_next(request) 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..793d723 --- /dev/null +++ b/backend/api/routes/admin_analytics.py @@ -0,0 +1,336 @@ +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"]) + +_LEGACY_NGINX_VISITS_TOTAL = 25070 +_LEGACY_NGINX_VISITORS_TOTAL = 6052 +_LEGACY_NGINX_VISITS_START_DATE = "2026-05-11" + + +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()] + + +async def _analytics_overview_payload() -> dict: + """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"), + }, + "traffic": { + "historical_visits": { + "total": _LEGACY_NGINX_VISITS_TOTAL + + await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view'"), + "legacy_total": _LEGACY_NGINX_VISITS_TOTAL, + "tracked_total": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view'"), + "start_date": _LEGACY_NGINX_VISITS_START_DATE, + "source": "one-time nginx estimate plus page.view events", + }, + "historical_visitors": { + "total": _LEGACY_NGINX_VISITORS_TOTAL + + await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "legacy_total": _LEGACY_NGINX_VISITORS_TOTAL, + "tracked_total": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "start_date": _LEGACY_NGINX_VISITS_START_DATE, + "source": "one-time nginx ip+ua estimate plus distinct page.view ip+ua", + }, + }, + "activity": { + "events_today": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE date(created_at)=date('now','localtime')"), + "events_7d": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE created_at >= datetime('now','localtime','-7 days')"), + "events_total": await _scalar("SELECT COUNT(*) FROM user_activity_events"), + "pageviews_today": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view' AND date(created_at)=date('now','localtime')"), + "pageviews_7d": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view' AND created_at >= datetime('now','localtime','-7 days')"), + "pageviews_total": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='page.view'"), + "visitors_today": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND date(created_at)=date('now','localtime') + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "visitors_7d": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND created_at >= datetime('now','localtime','-7 days') + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "visitors_total": await _scalar( + """ + SELECT COUNT(*) FROM ( + SELECT DISTINCT ip_hash, user_agent + FROM user_activity_events + WHERE event_name='page.view' + AND (ip_hash != '' OR user_agent != '') + ) + """ + ), + "logins_today": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='auth.login' AND date(created_at)=date('now','localtime')"), + "logins_7d": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='auth.login' AND created_at >= datetime('now','localtime','-7 days')"), + "logins_total": await _scalar("SELECT COUNT(*) FROM user_activity_events WHERE event_name='auth.login'"), + "events_today_by_name": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + WHERE date(created_at)=date('now','localtime') + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + "events_7d_by_name": 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 + """ + ), + "events_total_by_name": await _rows( + """ + SELECT event_name, COUNT(*) AS count + FROM user_activity_events + GROUP BY event_name + ORDER BY count DESC + LIMIT 12 + """ + ), + }, + "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 + """ + ), + }, + } + + +@router.get("/admin/analytics/overview") +async def admin_analytics_overview(_: int = Depends(get_current_root_user)): + return await _analytics_overview_payload() + + +@router.get("/admin/console/summary") +async def admin_console_summary(_: int = Depends(get_current_root_user)): + return await _analytics_overview_payload() 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/pages.py b/backend/api/routes/pages.py index 8658c24..76f462c 100644 --- a/backend/api/routes/pages.py +++ b/backend/api/routes/pages.py @@ -7,23 +7,50 @@ from typing import Optional from fastapi import APIRouter -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from PIL import Image, ImageDraw router = APIRouter(tags=["pages"]) -def _load_web_page_html(filename: str) -> str: - project_root = Path(__file__).resolve().parent.parent.parent.parent - html_path = project_root / "webconfig" / filename - if not html_path.exists(): - raise FileNotFoundError(f"Static page not found in webconfig: {filename}") - html = html_path.read_text(encoding="utf-8") - if "/webconfig/i18n.js" not in html: - html = html.replace("", '') - if "/webconfig/role-banner.js" not in html: - html = html.replace("", '') - return html +def _project_root() -> Path: + return Path(__file__).resolve().parent.parent.parent.parent + + +def _backend_root() -> Path: + return Path(__file__).resolve().parent.parent.parent + + +def _console_index_path() -> Path: + return _backend_root() / "static" / "console" / "index.html" + + +def _read_file_response(root: Path, asset_path: str, not_found_message: str) -> Response | JSONResponse: + root = root.resolve() + file_path = (root / asset_path).resolve() + try: + file_path.relative_to(root) + except ValueError: + return JSONResponse({"error": "asset_not_found", "message": not_found_message}, status_code=404) + if file_path != root and file_path.exists() and file_path.is_file(): + media_type, _ = mimetypes.guess_type(str(file_path)) + return Response(content=file_path.read_bytes(), media_type=media_type or "application/octet-stream") + return JSONResponse({"error": "asset_not_found", "message": not_found_message}, status_code=404) + + +def _primary_webapp_base() -> str: + return os.getenv("INKSIGHT_PRIMARY_WEBAPP_URL", "").strip().rstrip("/") + + +def _primary_webapp_url(path: str, mac: Optional[str] = None) -> str: + base = _primary_webapp_base() + if not base: + return "" + target = f"{base}{path}" + if mac: + separator = "&" if "?" in target else "?" + target = f"{target}{separator}mac={mac}" + return target def _build_primary_config_url(mac: Optional[str] = None) -> Optional[str]: @@ -37,21 +64,12 @@ def _build_primary_config_url(mac: Optional[str] = None) -> Optional[str]: def _legacy_config_bridge_html(mac: Optional[str] = None) -> str: - primary_url = _build_primary_config_url(mac) - primary_link = ( - f'' - "Open primary config" - "" + primary_url = _build_primary_config_url(mac) or _primary_webapp_url("/config", mac) + primary_action = ( + f'Open primary config' if primary_url - else ( - '' - "INKSIGHT_PRIMARY_WEBAPP_URL" - "" - ) + else 'Set INKSIGHT_PRIMARY_WEBAPP_URL to enable redirects' ) - legacy_href = f"/legacy/config?mac={mac}" if mac else "/legacy/config" return f""" @@ -65,30 +83,56 @@ def _legacy_config_bridge_html(mac: Optional[str] = None) -> str:

Device configuration moved to the web app.

The backend no longer serves the legacy config page at /config. - Use the primary web app for daily device configuration. Legacy webconfig remains available only for diagnostics and mode authoring. + Use the primary web app for daily device configuration.

- {primary_link} - - Open legacy config - + {primary_action}

- If you want automatic redirects here, set INKSIGHT_PRIMARY_WEBAPP_URL to your web app base URL. + Legacy webconfig HTML has been retired. Device APIs remain available on this backend. +

+ + +""" + + +def _legacy_removed_html(title: str, target_url: str) -> str: + primary_action = ( + f'Open primary web app' + if target_url + else 'Set INKSIGHT_PRIMARY_WEBAPP_URL to enable web app links' + ) + return f""" + + + + + {title} + + +
+

Legacy page retired

+

{title} moved to the primary web app.

+

+ This backend host now focuses on device APIs and rendering. Use the primary web app for browser UI.

+ {primary_action}
""" @router.get("/", response_class=HTMLResponse) -async def preview_page(): - return HTMLResponse(content=_load_web_page_html("preview.html")) +async def backend_landing_page(): + return FileResponse(_console_index_path(), media_type="text/html") @router.get("/preview", response_class=HTMLResponse) async def preview_page_alias(): - return HTMLResponse(content=_load_web_page_html("preview.html")) + target = _primary_webapp_url("/preview") + if target: + return RedirectResponse(url=target, status_code=307) + return HTMLResponse(content=_legacy_removed_html("Preview", target), status_code=410) @router.get("/config", response_class=HTMLResponse) @@ -101,36 +145,44 @@ async def config_page(mac: Optional[str] = None): @router.get("/legacy/config", response_class=HTMLResponse) async def legacy_config_page(): - return HTMLResponse(content=_load_web_page_html("config.html")) + return HTMLResponse(content=_legacy_removed_html("Device configuration", _primary_webapp_url("/config")), status_code=410) @router.get("/dashboard", response_class=HTMLResponse) async def dashboard_page(): - return HTMLResponse(content=_load_web_page_html("dashboard.html")) + target = _primary_webapp_url("/config") + if target: + return RedirectResponse(url=target, status_code=307) + return HTMLResponse(content=_legacy_removed_html("Dashboard", target), status_code=410) @router.get("/editor", response_class=HTMLResponse) async def editor_page(): - return HTMLResponse(content=_load_web_page_html("editor.html")) + target = _primary_webapp_url("/config") + if target: + return RedirectResponse(url=target, status_code=307) + return HTMLResponse(content=_legacy_removed_html("Mode editor", target), status_code=410) @router.get("/webconfig/{asset_path:path}") async def webconfig_asset(asset_path: str): - project_root = Path(__file__).resolve().parent.parent.parent.parent - file_path = (project_root / "webconfig" / asset_path).resolve() - webconfig_root = (project_root / "webconfig").resolve() - if not str(file_path).startswith(str(webconfig_root)) or not file_path.exists() or not file_path.is_file(): - return JSONResponse( - {"error": "asset_not_found", "message": "Webconfig asset not found"}, - status_code=404, + if asset_path.startswith("assets/art/"): + return _read_file_response( + _backend_root() / "static" / "art", + asset_path[len("assets/art/"):], + "Static art asset not found", ) - media_type, _ = mimetypes.guess_type(str(file_path)) - return Response(content=file_path.read_bytes(), media_type=media_type or "application/octet-stream") + return _read_file_response(_project_root() / "webconfig", asset_path, "Webconfig asset not found") + + +@router.get("/static/{asset_path:path}") +async def static_asset(asset_path: str): + return _read_file_response(_backend_root() / "static", asset_path, "Static asset not found") @router.get("/thumbs/{filename}") async def get_thumb(filename: str): - project_root = Path(__file__).resolve().parent.parent.parent.parent + project_root = _project_root() thumb_path = project_root / "webconfig" / "thumbs" / filename if thumb_path.exists() and thumb_path.is_file(): return Response(content=thumb_path.read_bytes(), media_type="image/png") 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/core/auth.py b/backend/core/auth.py index 6c33e1d..355c379 100644 --- a/backend/core/auth.py +++ b/backend/core/auth.py @@ -39,7 +39,7 @@ def _load_jwt_secret() -> str: _JWT_SECRET = _load_jwt_secret() _JWT_ALGORITHM = "HS256" -_JWT_EXPIRE_DAYS = 30 +_JWT_EXPIRE_DAYS = 1 _COOKIE_NAME = "ink_session" _MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") @@ -283,4 +283,4 @@ async def get_current_root_user( detail=msg("auth.root_required", detect_lang_from_request(request)) ) - return user_id \ No newline at end of file + return user_id diff --git a/backend/core/json_renderer.py b/backend/core/json_renderer.py index 3c20969..ecc4e2b 100644 --- a/backend/core/json_renderer.py +++ b/backend/core/json_renderer.py @@ -2469,12 +2469,27 @@ def _render_icon_list(ctx: RenderContext, block: dict) -> None: def _resolve_local_asset(url: str) -> str | None: """Resolve known local URLs to local filesystem paths.""" - if url.startswith("/webconfig/"): - project_root = Path(__file__).resolve().parent.parent.parent - local = project_root / "webconfig" / url[len("/webconfig/"):] - if local.exists() and local.is_file(): + backend_root = Path(__file__).resolve().parent.parent + repo_root = backend_root.parent + + def _resolve_under(root: Path, rel_path: str) -> str | None: + root = root.resolve() + local = (root / rel_path).resolve() + try: + local.relative_to(root) + except ValueError: + return None + if local != root and local.exists() and local.is_file(): return str(local) return None + + if url.startswith("/static/"): + return _resolve_under(backend_root / "static", url[len("/static/"):]) + if url.startswith("/webconfig/"): + legacy_path = url[len("/webconfig/"):] + if legacy_path.startswith("assets/art/"): + return _resolve_under(backend_root / "static" / "art", legacy_path[len("assets/art/"):]) + return _resolve_under(repo_root / "webconfig", legacy_path) try: parsed = urlparse(url) except ValueError: diff --git a/backend/core/modes/builtin/artwall.json b/backend/core/modes/builtin/artwall.json index 6dd8907..3eace20 100644 --- a/backend/core/modes/builtin/artwall.json +++ b/backend/core/modes/builtin/artwall.json @@ -9,33 +9,33 @@ "provider": "text2image", "fallback": { "artwork_title": "墨韵天成", - "image_url": "/webconfig/assets/art/moyun.png", + "image_url": "/static/art/moyun.png", "description": "今日艺术作品" }, "fallback_pool": [ { "artwork_title": "墨韵天成", - "image_url": "/webconfig/assets/art/moyun.png", + "image_url": "/static/art/moyun.png", "description": "今日艺术作品" }, { "artwork_title": "山居秋暝", - "image_url": "/webconfig/assets/art/shanjuqiuming.png", + "image_url": "/static/art/shanjuqiuming.png", "description": "水墨山水意境" }, { "artwork_title": "竹林七贤", - "image_url": "/webconfig/assets/art/zhulinqixian.png", + "image_url": "/static/art/zhulinqixian.png", "description": "古典人物版画" }, { "artwork_title": "禅意莲花", - "image_url": "/webconfig/assets/art/chanyilianhua.png", + "image_url": "/static/art/chanyilianhua.png", "description": "极简禅宗美学" }, { "artwork_title": "云卷云舒", - "image_url": "/webconfig/assets/art/yunjuanyunshu.png", + "image_url": "/static/art/yunjuanyunshu.png", "description": "黑白云海写意" } ] @@ -116,4 +116,4 @@ ] } } -} \ No newline at end of file +} diff --git a/backend/core/modes/builtin/en/artwall.json b/backend/core/modes/builtin/en/artwall.json index 12d85de..4f65eab 100644 --- a/backend/core/modes/builtin/en/artwall.json +++ b/backend/core/modes/builtin/en/artwall.json @@ -9,33 +9,33 @@ "provider": "text2image", "fallback": { "artwork_title": "Ink Muse", - "image_url": "/webconfig/assets/art/moyun.png", + "image_url": "/static/art/moyun.png", "description": "Today's artwork" }, "fallback_pool": [ { "artwork_title": "Ink Muse", - "image_url": "/webconfig/assets/art/moyun.png", + "image_url": "/static/art/moyun.png", "description": "Today's artwork" }, { "artwork_title": "Mountain Dusk", - "image_url": "/webconfig/assets/art/shanjuqiuming.png", + "image_url": "/static/art/shanjuqiuming.png", "description": "Ink wash landscape" }, { "artwork_title": "Bamboo Sages", - "image_url": "/webconfig/assets/art/zhulinqixian.png", + "image_url": "/static/art/zhulinqixian.png", "description": "Classical woodcut figures" }, { "artwork_title": "Zen Lotus", - "image_url": "/webconfig/assets/art/chanyilianhua.png", + "image_url": "/static/art/chanyilianhua.png", "description": "Minimalist zen aesthetics" }, { "artwork_title": "Cloud Drift", - "image_url": "/webconfig/assets/art/yunjuanyunshu.png", + "image_url": "/static/art/yunjuanyunshu.png", "description": "Black and white cloud sea" } ] 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/webconfig/assets/art/chanyilianhua.png b/backend/static/art/chanyilianhua.png similarity index 100% rename from webconfig/assets/art/chanyilianhua.png rename to backend/static/art/chanyilianhua.png diff --git a/webconfig/assets/art/moyun.png b/backend/static/art/moyun.png similarity index 100% rename from webconfig/assets/art/moyun.png rename to backend/static/art/moyun.png diff --git a/webconfig/assets/art/shanjuqiuming.png b/backend/static/art/shanjuqiuming.png similarity index 100% rename from webconfig/assets/art/shanjuqiuming.png rename to backend/static/art/shanjuqiuming.png diff --git a/webconfig/assets/art/yunjuanyunshu.png b/backend/static/art/yunjuanyunshu.png similarity index 100% rename from webconfig/assets/art/yunjuanyunshu.png rename to backend/static/art/yunjuanyunshu.png diff --git a/webconfig/assets/art/zhulinqixian.png b/backend/static/art/zhulinqixian.png similarity index 100% rename from webconfig/assets/art/zhulinqixian.png rename to backend/static/art/zhulinqixian.png diff --git a/backend/static/console/console.css b/backend/static/console/console.css new file mode 100644 index 0000000..e43dedd --- /dev/null +++ b/backend/static/console/console.css @@ -0,0 +1,305 @@ +:root { + color-scheme: light; + --ink: #17201b; + --muted: #647067; + --card: rgba(255, 255, 255, 0.82); + --line: #e8ddca; + --accent: #0f766e; + --bad: #b91c1c; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 8% 0%, #dcefd3 0, rgba(220, 239, 211, 0) 34%), + radial-gradient(circle at 90% 8%, #f3d8b5 0, rgba(243, 216, 181, 0) 28%), + linear-gradient(135deg, #fffaf0 0%, #f6eddf 100%); + color: var(--ink); + font: 15px/1.55 ui-serif, Georgia, Cambria, "Times New Roman", serif; +} + +main { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 42px 0 56px; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: flex-end; + margin-bottom: 22px; +} + +h1 { + margin: 0; + font-size: clamp(42px, 8vw, 84px); + line-height: 0.92; + letter-spacing: -0.07em; +} + +.eyebrow { + margin: 0 0 10px; + color: var(--accent); + font: 800 12px/1.2 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.sub { + margin: 14px 0 0; + max-width: 68ch; + color: var(--muted); +} + +.panel { min-width: min(100%, 560px); } + +.auth { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; +} + +input { + width: 180px; + padding: 11px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fffdf8; + color: var(--ink); + font: 14px ui-sans-serif, system-ui, sans-serif; +} + +button { + border: 0; + border-radius: 999px; + padding: 11px 15px; + background: #17201b; + color: #fff; + cursor: pointer; + font: 800 13px/1 ui-sans-serif, system-ui, sans-serif; +} + +button.secondary { + background: #fffdf8; + color: #334139; + border: 1px solid var(--line); +} + +.lang-switch { + display: flex; + gap: 4px; + margin-bottom: 8px; + justify-content: flex-end; +} + +.lang-switch button { + padding: 7px 11px; + font-size: 12px; + background: #fffdf8; + color: #334139; + border: 1px solid var(--line); +} + +.lang-switch button.active { + background: #17201b; + color: #fff; + border-color: #17201b; +} + +.status { + margin: 0 0 18px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.62); + color: var(--muted); + font-family: ui-sans-serif, system-ui, sans-serif; +} + +.status.error { + color: var(--bad); + border-color: #fecaca; + background: #fff1f2; +} + +.grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.charts { margin-top: 14px; } + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 24px; + padding: 18px; + box-shadow: 0 18px 50px rgba(74, 58, 34, 0.08); + backdrop-filter: blur(10px); +} + +.metric-label { + margin: 0; + color: var(--muted); + font: 800 12px/1.2 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.metric-value { + margin: 10px 0 4px; + font-size: 34px; + line-height: 1; + letter-spacing: -0.04em; +} + +.metric-note { + margin: 0; + color: var(--muted); + font-size: 13px; +} + +.metric-unit { + font: 700 14px/1 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0; + color: var(--muted); +} + +.wide { grid-column: span 2; } + +table { + width: 100%; + border-collapse: collapse; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 13px; +} + +th, +td { + text-align: left; + padding: 10px 8px; + border-bottom: 1px solid #eee2ce; +} + +th { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.bars { + display: flex; + align-items: flex-end; + gap: 7px; + height: 142px; + padding-top: 14px; +} + +.bar { + flex: 1; + min-width: 10px; + border-radius: 999px 999px 4px 4px; + background: linear-gradient(180deg, #0f766e, #83b799); + position: relative; +} + +.bar span { + position: absolute; + left: 50%; + bottom: -24px; + transform: translateX(-50%) rotate(-38deg); + transform-origin: center; + color: var(--muted); + font-size: 10px; + white-space: nowrap; +} + +.empty { + color: var(--muted); + font-family: ui-sans-serif, system-ui, sans-serif; +} + +.footnote { + margin-top: 14px; + color: var(--muted); +} + +.footnote p:last-child { margin-bottom: 0; } + +.mini-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.mini-metrics div { + border: 1px solid #eee2ce; + border-radius: 18px; + background: rgba(255, 253, 248, 0.72); + padding: 14px; +} + +.mini-metrics p { + margin: 0 0 8px; + color: var(--muted); + font: 800 11px/1.2 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.mini-metrics strong { + display: block; + font-size: 28px; + line-height: 1; + letter-spacing: -0.04em; +} + +.mini-metrics span { + display: block; + margin-top: 7px; + color: var(--muted); + font: 12px/1.35 ui-sans-serif, system-ui, sans-serif; +} + +.section-title { + margin: 22px 0 10px; + font: 700 26px/1 ui-serif, Georgia, Cambria, "Times New Roman", serif; + letter-spacing: -0.04em; +} + +.event-groups { + display: grid; + gap: 16px; + margin-top: 18px; +} + +.event-groups section > p { + margin: 0 0 8px; + color: var(--muted); + font: 800 11px/1.2 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@media (max-width: 900px) { + .hero { + align-items: flex-start; + flex-direction: column; + } + + .auth { justify-content: flex-start; } + .lang-switch { justify-content: flex-start; } + .grid { grid-template-columns: 1fr; } + .wide { grid-column: auto; } + input { width: 100%; } +} diff --git a/backend/static/console/console.js b/backend/static/console/console.js new file mode 100644 index 0000000..e835bb5 --- /dev/null +++ b/backend/static/console/console.js @@ -0,0 +1,389 @@ +const $ = (id) => document.getElementById(id); + +const I18N = { + zh: { + pageTitle: "InkSight 控制台", + eyebrow: "InkSight Console", + title: "后端运营看板", + subtitle: "汇总 InkSight 的用户增长、设备活跃、渲染质量、内容创作与访问埋点。", + username: "用户名", + password: "密码", + signIn: "登录", + signOut: "退出", + statusIdle: "请使用 root 账号登录以加载受保护的指标。设备 API 仍可通过 /api/* 访问。", + statusLoading: "正在加载指标...", + statusNeedLogin: "请使用 root 账号登录以加载指标。", + statusNotRoot: "当前账号不是 root。", + statusLoaded: "Root 会话已在此后端域名生效,已于 {time} 加载。", + statusLoginRequired: "用户名和密码不能为空。", + statusSigningIn: "正在登录...", + statusSignInFailed: "登录失败:{message}", + statusSignedOut: "已在此后端域名退出登录。", + statusLoadFailed: "加载看板指标失败:{message}", + metricUsers: "用户", + metricDau: "日活", + metricActiveDevices: "今日活跃设备", + metricRenders: "今日渲染", + metricHeartbeats: "设备心跳", + metricRenderHealth: "今日渲染健康", + metricContent: "内容创作", + metricDeviceUsers: "今日活跃设备用户", + metricNewUsers: "14 日新增用户", + metricRendersSeries: "14 日渲染量", + metricActiveDeviceSeries: "14 日活跃设备", + metricActivitySeries: "14 日用户活跃", + metricTopModes: "7 日热门模式", + metricTopEvents: "7 日用户事件", + metricEventSummary: "用户事件总览", + metricHistoricalVisits: "历史访问量", + metricHistoricalVisitors: "历史访客数", + metricEventsToday: "今日用户事件", + metricEvents7d: "7 日用户事件", + metricEventsTotal: "历史用户事件", + trackingDataTitle: "埋点数据 · 统计自 2026-05-25", + metricNotes: "统计口径", + notesBody: "注册数来自 users;DAU/WAU/MAU 来自 user_activity_events;设备活跃来自心跳和渲染日志;渲染量、错误与 fallback 来自 render_logs。历史访问量和历史访客数含一次性 Nginx 粗估基线,后续增长以 page.view 埋点为准。", + noData: "暂无数据", + usersNote: "今日 +{today} / 7日 +{week} / 30日 +{month}", + activeNote: "WAU {wau} / MAU {mau}", + devicesNote: "已绑定 {bound} / 7日活跃 {week}", + rendersNote: "7日 {week} / 今日平均耗时 {avg}ms", + heartbeatsNote: "今日设备心跳请求", + renderHealthNote: "今日错误 {errors} / fallback 占比 {rate}", + contentNote: "共享 {shared} / BYOK {byok}", + deviceUsersNote: "已绑定用户 {withDevice} / 设备反推 24h 活跃", + historicalVisitsNote: "统计自 {start};后续使用埋点", + historicalVisitorsNote: "IP+UA 去重;统计自 {start}", + eventRows: "事件记录", + colMode: "模式", + colEvent: "事件", + colRenders: "渲染次数", + colCount: "次数", + colEvents: "事件数", + colPeriod: "周期", + colPageviews: "访问量", + colVisitors: "访客数", + colLogins: "登录", + periodToday: "今日", + period7d: "7 日", + periodTotal: "历史", + }, + en: { + pageTitle: "InkSight Console", + eyebrow: "InkSight Console", + title: "Backend pulse.", + subtitle: "A daily operations view for InkSight user growth, device activity, render health, content creation, and tracking data.", + username: "Username", + password: "Password", + signIn: "Sign in", + signOut: "Sign out", + statusIdle: "Sign in with a root account to load protected metrics. Device APIs remain available under /api/*.", + statusLoading: "Loading metrics...", + statusNeedLogin: "Sign in with a root account to load metrics.", + statusNotRoot: "Current account is not root.", + statusLoaded: "Root session is active on this backend domain. Loaded at {time}.", + statusLoginRequired: "Username and password are required.", + statusSigningIn: "Signing in...", + statusSignInFailed: "Sign-in failed: {message}", + statusSignedOut: "Signed out on this backend domain.", + statusLoadFailed: "Failed to load console metrics: {message}", + metricUsers: "Users", + metricDau: "DAU", + metricActiveDevices: "Active Devices Today", + metricRenders: "Renders Today", + metricHeartbeats: "Heartbeats", + metricRenderHealth: "Render Health Today", + metricContent: "Content", + metricDeviceUsers: "Active Device Users Today", + metricNewUsers: "New Users 14d", + metricRendersSeries: "Renders 14d", + metricActiveDeviceSeries: "Active Devices 14d", + metricActivitySeries: "User Activity 14d", + metricTopModes: "Top Modes 7d", + metricTopEvents: "User Events 7d", + metricEventSummary: "Event Summary", + metricHistoricalVisits: "Historical Visits", + metricHistoricalVisitors: "Historical Visitors", + metricEventsToday: "Events Today", + metricEvents7d: "User Events 7d", + metricEventsTotal: "Historical Events", + trackingDataTitle: "Tracking Data · Since 2026-05-25", + metricNotes: "Notes", + notesBody: "Users come from users; DAU/WAU/MAU come from user_activity_events; device activity comes from heartbeats and render logs; renders, errors, and fallback counts come from render_logs. Historical visits and visitors include one-time Nginx estimate baselines; future growth uses page.view events.", + noData: "No data yet.", + usersNote: "+{today} today / +{week} 7d / +{month} 30d", + activeNote: "WAU {wau} / MAU {mau}", + devicesNote: "{bound} bound / {week} active 7d", + rendersNote: "{week} 7d / {avg}ms avg today", + heartbeatsNote: "device heartbeats today", + renderHealthNote: "{errors} errors today / {rate} fallback", + contentNote: "{shared} shared / {byok} BYOK", + deviceUsersNote: "{withDevice} bound users / device-derived 24h active", + historicalVisitsNote: "since {start}; tracked going forward", + historicalVisitorsNote: "distinct IP+UA since {start}", + eventRows: "event rows", + colMode: "Mode", + colEvent: "Event", + colRenders: "Renders", + colCount: "Count", + colEvents: "Events", + colPeriod: "Period", + colPageviews: "Views", + colVisitors: "Visitors", + colLogins: "Logins", + periodToday: "Today", + period7d: "7d", + periodTotal: "All time", + }, +}; + +let lang = localStorage.getItem("inksight_console_lang") || ""; +if (!I18N[lang]) { + lang = (navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"; +} + +let lastData = null; + +function t(key, vars = {}) { + let text = (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key; + Object.entries(vars).forEach(([name, value]) => { + text = text.replaceAll(`{${name}}`, String(value)); + }); + return text; +} + +function fmt(n) { + if (n === null || n === undefined) return "--"; + return Number(n || 0).toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); +} + +function pct(numerator, denominator) { + const den = Number(denominator || 0); + if (!den) return "0%"; + return `${((Number(numerator || 0) / den) * 100).toFixed(1)}%`; +} + +function setStatus(text, isError = false) { + const el = $("status"); + el.textContent = text; + el.className = isError ? "status error" : "status"; +} + +function applyLang() { + document.documentElement.lang = lang === "zh" ? "zh-CN" : "en"; + document.title = t("pageTitle"); + document.querySelectorAll("[data-i18n]").forEach((el) => { + el.textContent = t(el.dataset.i18n); + }); + document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => { + el.placeholder = t(el.dataset.i18nPlaceholder); + }); + $("langZh").classList.toggle("active", lang === "zh"); + $("langEn").classList.toggle("active", lang === "en"); +} + +function setLang(next) { + if (!I18N[next] || next === lang) return; + lang = next; + localStorage.setItem("inksight_console_lang", lang); + applyLang(); + if (lastData) render(lastData); + else setStatus(t("statusIdle")); +} + +function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + })[char]); +} + +function renderTable(rows, columns) { + if (!rows || rows.length === 0) return `

${t("noData")}

`; + const head = columns.map((c) => `${escapeHtml(c.label)}`).join(""); + const body = rows.map((row) => ( + `${columns.map((c) => `${escapeHtml(row[c.key] || "")}`).join("")}` + )).join(""); + return `${head}${body}
`; +} + +function renderBars(id, rows, key = "count") { + if (!rows || rows.length === 0) { + $(id).innerHTML = `

${t("noData")}

`; + return; + } + const ordered = [...rows].reverse(); + const max = Math.max(...ordered.map((d) => Number(d[key] || 0)), 1); + $(id).innerHTML = ordered.map((d) => { + const value = Number(d[key] || 0); + const height = Math.max(8, Math.round((value / max) * 116)); + const day = String(d.day || ""); + return `
${escapeHtml(day.slice(5))}
`; + }).join(""); +} + +function eventRows(rows, fallbackTotal) { + if (Array.isArray(rows)) return rows; + if (fallbackTotal === null || fallbackTotal === undefined) return []; + return [{ event_name: "all", count: fallbackTotal }]; +} + +function renderSummaryTable(rows) { + const columns = [ + { key: "period", label: t("colPeriod") }, + { key: "pageviews", label: t("colPageviews") }, + { key: "visitors", label: t("colVisitors") }, + { key: "logins", label: t("colLogins") }, + { key: "events", label: t("colEvents") }, + ]; + return renderTable(rows, columns); +} + +function render(data) { + lastData = data; + $("usersTotal").textContent = fmt(data.users.total); + $("usersNote").textContent = t("usersNote", { + today: fmt(data.users.today_new), + week: fmt(data.users.new_7d), + month: fmt(data.users.new_30d), + }); + $("dau").textContent = fmt(data.users.dau); + $("activeNote").textContent = t("activeNote", { + wau: fmt(data.users.wau), + mau: fmt(data.users.mau), + }); + $("devicesActive").textContent = fmt(data.devices.active_today); + $("devicesNote").textContent = t("devicesNote", { + bound: fmt(data.devices.bound), + week: fmt(data.devices.active_7d), + }); + $("rendersToday").textContent = fmt(data.rendering.today); + $("rendersNote").textContent = t("rendersNote", { + week: fmt(data.rendering.last_7d), + avg: fmt(data.rendering.avg_ms_today), + }); + $("heartbeatsToday").textContent = fmt(data.devices.heartbeats_today); + $("heartbeatsNote").textContent = t("heartbeatsNote"); + $("renderErrors").innerHTML = `${fmt(data.rendering.fallback_today)} fallback`; + $("renderHealthNote").textContent = t("renderHealthNote", { + errors: fmt(data.rendering.errors_today), + rate: pct(data.rendering.fallback_today, data.rendering.today), + }); + $("customModes").textContent = fmt(data.content.custom_modes); + $("contentNote").textContent = t("contentNote", { + shared: fmt(data.content.shared_modes), + byok: fmt(data.content.users_with_llm_config), + }); + $("deviceUsers").textContent = fmt(data.users.device_active_24h); + $("deviceUsersNote").textContent = t("deviceUsersNote", { + withDevice: fmt(data.users.with_device), + }); + + renderBars("userBars", data.series.new_users); + renderBars("renderBars", data.series.renders); + renderBars("deviceBars", data.series.active_devices); + renderBars("activityBars", data.series.activity_events, "active_users"); + $("topModes").innerHTML = renderTable(data.top.modes, [ + { key: "mode", label: t("colMode") }, + { key: "count", label: t("colRenders") }, + ]); + $("historicalVisits").textContent = fmt(data.traffic.historical_visits.total); + $("historicalVisitsNote").textContent = t("historicalVisitsNote", { + start: data.traffic.historical_visits.start_date, + }); + $("historicalVisitors").textContent = fmt(data.traffic.historical_visitors.total); + $("historicalVisitorsNote").textContent = t("historicalVisitorsNote", { + start: data.traffic.historical_visitors.start_date, + }); + $("eventSummaryTable").innerHTML = renderSummaryTable([ + { + period: t("periodToday"), + pageviews: fmt(data.activity.pageviews_today), + visitors: fmt(data.activity.visitors_today), + logins: fmt(data.activity.logins_today), + events: fmt(data.activity.events_today), + }, + { + period: t("period7d"), + pageviews: fmt(data.activity.pageviews_7d), + visitors: fmt(data.activity.visitors_7d), + logins: fmt(data.activity.logins_7d), + events: fmt(data.activity.events_7d), + }, + { + period: t("periodTotal"), + pageviews: fmt(data.activity.pageviews_total), + visitors: fmt(data.activity.visitors_total), + logins: fmt(data.activity.logins_total), + events: fmt(data.activity.events_total), + }, + ]); + setStatus(t("statusLoaded", { time: new Date().toLocaleString(lang === "zh" ? "zh-CN" : "en-US") })); +} + +async function loadConsole() { + setStatus(t("statusLoading")); + try { + const res = await fetch("/api/admin/console/summary", { cache: "no-store", credentials: "same-origin" }); + if (res.status === 401) { + setStatus(t("statusNeedLogin")); + return; + } + if (res.status === 403) throw new Error(t("statusNotRoot")); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + render(await res.json()); + } catch (err) { + setStatus(t("statusLoadFailed", { message: err.message }), true); + } +} + +async function login(event) { + event.preventDefault(); + const username = $("username").value.trim(); + const password = $("password").value; + if (!username || !password) { + setStatus(t("statusLoginRequired"), true); + return; + } + setStatus(t("statusSigningIn")); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + $("password").value = ""; + await loadConsole(); + } catch (err) { + setStatus(t("statusSignInFailed", { message: err.message }), true); + } +} + +async function logout() { + await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }).catch(() => {}); + lastData = null; + setStatus(t("statusSignedOut")); +} + +function trackPageview() { + fetch("/api/analytics/pageview", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ path: location.pathname, source: "backend_console" }), + keepalive: true, + }).catch(() => {}); +} + +$("loginForm").addEventListener("submit", login); +$("logout").addEventListener("click", logout); +$("langZh").addEventListener("click", () => setLang("zh")); +$("langEn").addEventListener("click", () => setLang("en")); +applyLang(); +trackPageview(); +loadConsole(); diff --git a/backend/static/console/index.html b/backend/static/console/index.html new file mode 100644 index 0000000..58e4a05 --- /dev/null +++ b/backend/static/console/index.html @@ -0,0 +1,74 @@ + + + + + + InkSight Console + + + + +
+
+
+

InkSight Console

+

Backend pulse.

+

+ InkSight user growth, device activity, render health, content creation, and tracking data. +

+
+
+
+ + +
+
+ + + + +
+
+
+ +

Sign in with a root account to load protected metrics.

+ +
+

Users

--

--

+

DAU

--

--

+

Active Devices Today

--

--

+

Renders Today

--

--

+

Heartbeats

--

--

+

Render Health Today

--

--

+

Content

--

--

+

Active Device Users Today

--

--

+
+ +
+

New Users 14d

+

Renders 14d

+

Active Devices 14d

+

User Activity 14d

+

Top Modes 7d

+
+

Event Summary

+
+

Historical Visits

----
+

Historical Visitors

----
+
+

Tracking Data · Since 2026-05-25

+
+
+
+
+
+ +
+

Notes

+

+ Users come from users; DAU/WAU/MAU come from user_activity_events; device activity comes from heartbeats and render logs. +

+
+
+ + diff --git a/backend/tests/test_integration.py b/backend/tests/test_integration.py index 09ca7ae..0289be5 100644 --- a/backend/tests/test_integration.py +++ b/backend/tests/test_integration.py @@ -1154,19 +1154,6 @@ async def test_custom_modes_end_to_end(client, monkeypatch): # --------------------------------------------------------------------------- -@pytest.mark.asyncio -async def test_config_page_bridge_and_legacy_page(client): - bridge_resp = await client.get("/config") - assert bridge_resp.status_code == 200 - assert "Device configuration moved to the web app." in bridge_resp.text - assert "/legacy/config" in bridge_resp.text - - legacy_resp = await client.get("/legacy/config") - assert legacy_resp.status_code == 200 - assert "legacy-console-banner" not in legacy_resp.text - assert "/webconfig/role-banner.js" in legacy_resp.text - - @pytest.mark.asyncio async def test_config_page_redirects_to_primary_webapp_when_configured(client, monkeypatch): monkeypatch.setenv("INKSIGHT_PRIMARY_WEBAPP_URL", "https://app.example.com") 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}