From d5f3784ffb2b4c5f34d4610ec3d3e32787fbeaa1 Mon Sep 17 00:00:00 2001 From: linkong Date: Thu, 23 Apr 2026 17:57:35 +0800 Subject: [PATCH] release: bump version to 0.38.0 --- TODO.md | 3 + VERSION | 2 +- backend/app/api/v1/system_control.py | 189 ++++- backend/app/api/v1/visualization.py | 25 + backend/app/core/request_context.py | 14 + backend/app/db/session.py | 22 + backend/app/main.py | 15 + backend/app/models/__init__.py | 3 + backend/app/models/system_log.py | 40 ++ backend/app/services/earth_news.py | 50 ++ backend/app/services/persistent_logs.py | 78 ++ backend/app/services/system_logs.py | 522 ++++++++++++++ backend/tests/test_api.py | 344 ++++++++- backend/tests/test_earth_news.py | 49 ++ backend/tests/test_system_logs.py | 164 +++++ docs/CHANGELOG.md | 18 + docs/plans/enterprise-logging-system-plan.md | 665 ++++++++++++++++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 583 ++++++++++++++- frontend/public/earth/css/info-panel.css | 107 +++ frontend/public/earth/css/news-panel.css | 9 + frontend/public/earth/index.html | 116 ++- frontend/public/earth/js/client-logs.js | 96 +++ frontend/public/earth/js/constants.js | 8 + frontend/public/earth/js/controls.js | 116 ++- frontend/public/earth/js/earth.js | 2 - frontend/public/earth/js/info-card.js | 265 +++++-- frontend/public/earth/js/main.js | 295 +++++++- .../public/earth/js/news-cruise-adapter.js | 357 ++++++++++ frontend/public/earth/js/news.js | 57 +- frontend/public/earth/js/tv.js | 137 +++- frontend/src/App.tsx | 2 + .../src/components/AppLayout/AppLayout.tsx | 3 + frontend/src/index.css | 223 ++++++ frontend/src/pages/Logs/Logs.tsx | 515 ++++++++++++++ planet.sh | 1 + pyproject.toml | 2 +- uv.lock | 2 +- 39 files changed, 4958 insertions(+), 146 deletions(-) create mode 100644 backend/app/core/request_context.py create mode 100644 backend/app/models/system_log.py create mode 100644 backend/app/services/persistent_logs.py create mode 100644 backend/app/services/system_logs.py create mode 100644 backend/tests/test_earth_news.py create mode 100644 backend/tests/test_system_logs.py create mode 100644 docs/plans/enterprise-logging-system-plan.md create mode 100644 frontend/public/earth/js/client-logs.js create mode 100644 frontend/public/earth/js/news-cruise-adapter.js create mode 100644 frontend/src/pages/Logs/Logs.tsx diff --git a/TODO.md b/TODO.md index 82c462a8..bd87d98a 100644 --- a/TODO.md +++ b/TODO.md @@ -22,8 +22,11 @@ - [ ] 可选优化(非必做):将 BGP incident/collector 标点改为 HTML marker(参考 worldmonitor 的 `htmlElementsData` 思路),实现近乎固定屏幕尺寸与更高密度可点击性 - [ ] 保持 Earth 当前这批纯个人偏好设置继续走本地持久化:`旋转模式`、HUD 面板显示/隐藏、`地形透明度` 暂不升级到后端系统设置,避免把设备级偏好过早做成全局配置 - [ ] 如果后续明确需要“账号级同步 Earth 偏好”,再单独设计 `Earth user preferences`:优先按用户维度而不是全局系统设置保存,并规划 `localStorage -> backend` 的平滑迁移策略 +- [ ] 为 Planet / Earth 补一个可用的日志查看系统:先明确前后端/AI Provider/采集任务的日志入口、最近日志聚合、筛选与 tail 能力,再决定是先做脚本级统一入口还是控制台内置日志面板 - [ ] 把 Earth 态势新闻源从 [earth_news.py](/home/ray/dev/linkong/planet/backend/app/services/earth_news.py) 的硬编码列表抽成可配置目录,优先保持当前“实时聚合”链路不变,只先解决新闻源不可配置的问题 - [ ] 为 Earth 态势新闻设计后续采集器化方案:明确新闻数据模型、去重策略、区域映射、过期清理和 Earth/AI 复用方式,再决定何时把新闻从实时抓取升级成正式 collector +- [ ] 为 Earth 地球表面增加一层与基础纹理对齐的材质/纹理 overlay,并在同层叠加国界轮廓参考线;要求国界线与底图稳定对齐,且 hover 到国家轮廓时能高亮当前国家,便于校准地表和增强交互 +- [ ] 把 Earth 新闻接入通用巡航队列:按新闻发生地和时间排序生成巡航目标,巡航聚焦到新闻事件时显示对应新闻卡片,并保持实现边界为“通用巡航层 + 新闻业务适配层”,不要再把新闻逻辑直接耦合回 `main.js` 状态机 - [ ] 为未知位置的算力中心建立分层坐标补全链路:优先 `精确坐标 > 站点/园区命中 > 城市 > 州/省 > 国家内主要算力城市 > 国家质心`,并把每次回退的 `confidence / reason / precision` 明确写进统一 GeoJSON - [ ] 为算力中心补一份可维护的本地位置注册表,例如 `canonical_name / aliases / operator / country / region / city / lat / lon / confidence / source_note`,避免把地点知识长期硬编码在 `visualization.py` - [ ] 增强 `epoch_ai_gpu` 和相关算力采集器的源页面解析:即使公开 API 不给坐标,也继续尝试从详情页、HTML、内嵌 JSON、schema.org、OpenGraph、脚本变量和 PDF/新闻稿链接里抽地点线索 diff --git a/VERSION b/VERSION index 8570a3ae..ca75280b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.37.2 +0.38.0 diff --git a/backend/app/api/v1/system_control.py b/backend/app/api/v1/system_control.py index 21c81208..254fe353 100644 --- a/backend/app/api/v1/system_control.py +++ b/backend/app/api/v1/system_control.py @@ -4,12 +4,15 @@ import os import subprocess import sys -from fastapi import APIRouter, Depends, HTTPException, status +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel from app.core.config import ROOT_DIR from app.core.security import get_current_user from app.models.user import User +from app.services.persistent_logs import record_audit_log, record_system_log from app.services.system_control import ( build_task_id, clear_active_task_id, @@ -23,6 +26,15 @@ from app.services.system_control import ( set_active_task_id, upsert_task_state, ) +from app.services.system_logs import ( + DEFAULT_LOG_LINE_LIMIT, + MAX_LOG_LINE_LIMIT, + SUPPORTED_LOG_LEVELS, + append_buffer_log, + list_log_sources, + normalize_log_level, + read_log_snapshot, +) router = APIRouter() @@ -47,6 +59,59 @@ class RestartTaskLogsResponse(BaseModel): lines: list[str] +class SystemLogSourceSummary(BaseModel): + source_id: str + name: str + kind: str + location: str + description: str + category: str + status: str + + +class SystemLogSourcesResponse(BaseModel): + items: list[SystemLogSourceSummary] + + +class SystemLogDailyMarker(BaseModel): + date_token: str + total: int + dominant_level: str + + +class SystemLogSnapshotResponse(BaseModel): + source_id: str + name: str + kind: str + location: str + description: str + category: str + status: str + level: str + selected_levels: list[str] = [] + search_query: str = "" + available_levels: list[str] + daily_markers: list[SystemLogDailyMarker] = [] + line_limit: int + line_count: int + lines: list[str] + + +class EarthClientLogEventCreate(BaseModel): + level: str = "error" + message: str + category: str | None = None + url: str | None = None + module: str | None = None + detail: str | None = None + + +class EarthClientLogEventResponse(BaseModel): + accepted: bool + source_id: str + level: str + + def ensure_super_admin(current_user: User) -> None: if not require_super_admin(current_user.role): raise HTTPException( @@ -55,9 +120,22 @@ def ensure_super_admin(current_user: User) -> None: ) +def validate_log_date(raw_value: str | None, field_name: str) -> str | None: + if raw_value in {None, ""}: + return None + try: + return datetime.strptime(raw_value, "%Y-%m-%d").date().isoformat() + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{field_name} must be in YYYY-MM-DD format", + ) from exc + + @router.post("/restart-tasks", response_model=RestartTaskResponse) async def create_restart_task( payload: RestartTaskCreate, + request: Request, current_user: User = Depends(get_current_user), ): ensure_super_admin(current_user) @@ -133,11 +211,31 @@ async def create_restart_task( requested_by=requested_by, ) clear_active_task_id(task_id) + await record_audit_log( + action="system.restart_task.requested", + actor_id=current_user.id, + actor_name=current_user.username, + target_type="restart_task", + target_id=task_id, + result="failed", + ip=request.client.host if request.client else None, + details={"action": payload.action, "message": task_state["message"]}, + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=task_state["message"], ) from exc + await record_audit_log( + action="system.restart_task.requested", + actor_id=current_user.id, + actor_name=current_user.username, + target_type="restart_task", + target_id=task_id, + result="accepted", + ip=request.client.host if request.client else None, + details={"action": payload.action}, + ) return task_state @@ -165,3 +263,92 @@ async def get_restart_task_logs( if task is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Restart task not found") return {"task_id": task_id, "lines": get_task_logs(task_id)} + + +@router.get("/logs/sources", response_model=SystemLogSourcesResponse) +async def get_system_log_sources( + current_user: User = Depends(get_current_user), +): + ensure_super_admin(current_user) + return {"items": list_log_sources()} + + +@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse) +async def get_system_log_snapshot( + source_id: str, + limit: int = DEFAULT_LOG_LINE_LIMIT, + level: str = "all", + levels: str | None = Query(None, description="Comma-separated log levels"), + start_date: str | None = Query(None, description="Filter logs from this date (YYYY-MM-DD)"), + end_date: str | None = Query(None, description="Filter logs until this date (YYYY-MM-DD)"), + search: str | None = Query(None, description="Case-insensitive substring search"), + current_user: User = Depends(get_current_user), +): + ensure_super_admin(current_user) + + if limit < 1 or limit > MAX_LOG_LINE_LIMIT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}", + ) + if str(level).strip().lower() not in SUPPORTED_LOG_LEVELS and normalize_log_level(level) == "all" and str(level).strip().lower() not in {"", "all"}: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level") + if levels: + for raw_level in str(levels).split(","): + normalized_level = str(raw_level).strip().lower() + if not normalized_level: + continue + if normalized_level not in SUPPORTED_LOG_LEVELS and normalize_log_level(normalized_level) == "all": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported log level") + normalized_start_date = validate_log_date(start_date, "start_date") + normalized_end_date = validate_log_date(end_date, "end_date") + if normalized_start_date and normalized_end_date and normalized_start_date > normalized_end_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="start_date must be earlier than or equal to end_date") + + snapshot = read_log_snapshot( + source_id, + limit, + level=level, + levels=levels, + start_date=normalized_start_date, + end_date=normalized_end_date, + search=search, + ) + if snapshot is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Log source not found") + return snapshot + + +@router.post("/logs/earth-client", response_model=EarthClientLogEventResponse) +async def ingest_earth_client_log( + payload: EarthClientLogEventCreate, + request: Request, +): + normalized_level = normalize_log_level(payload.level) + append_buffer_log( + "earth-client", + level=normalized_level, + message=payload.message, + context={ + "category": payload.category or "", + "url": payload.url or "", + "module": payload.module or "", + "detail": payload.detail or "", + }, + ) + await record_system_log( + source="earth-client", + service="earth", + module=payload.module or "earth-client", + event="earth.client.runtime_log", + level=normalized_level, + message=payload.message, + category=payload.category or "client-runtime", + context={ + "url": payload.url or "", + "detail": payload.detail or "", + "module": payload.module or "", + "client_ip": request.client.host if request.client else "", + }, + ) + return {"accepted": True, "source_id": "earth-client", "level": normalized_level} diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index ac6198a1..be5fe15f 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -5,6 +5,7 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium. """ from datetime import UTC, datetime +import logging import math import httpx from fastapi import APIRouter, HTTPException, Depends, Query, Response @@ -23,8 +24,10 @@ from app.models.collected_data import CollectedData from app.services.bgp_collectors import build_bgp_collector_coverage from app.services.cable_graph import build_graph_from_data, CableGraph, haversine_distance from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS +from app.services.persistent_logs import record_system_log router = APIRouter() +logger = logging.getLogger(__name__) TERRAIN_TILE_URL_TEMPLATE = ( "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png" ) @@ -990,6 +993,17 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)): except HTTPException: raise except Exception as e: + logger.exception("Failed to build cables GeoJSON response") + await record_system_log( + source="backend", + service="api", + module=__name__, + event="visualization.cables.load_failed", + level="error", + message="Failed to build cables GeoJSON response", + category="visualization", + context={"error": str(e)}, + ) raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") @@ -1026,6 +1040,17 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)): except HTTPException: raise except Exception as e: + logger.exception("Failed to build landing points GeoJSON response") + await record_system_log( + source="backend", + service="api", + module=__name__, + event="visualization.landing_points.load_failed", + level="error", + message="Failed to build landing points GeoJSON response", + category="visualization", + context={"error": str(e)}, + ) raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}") diff --git a/backend/app/core/request_context.py b/backend/app/core/request_context.py new file mode 100644 index 00000000..de30d925 --- /dev/null +++ b/backend/app/core/request_context.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from contextvars import ContextVar + + +request_id_context: ContextVar[str | None] = ContextVar("request_id", default=None) + + +def set_request_id(request_id: str | None) -> None: + request_id_context.set(request_id) + + +def get_request_id() -> str | None: + return request_id_context.get() diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 4aed0860..8475f295 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -1,3 +1,4 @@ +import logging from typing import AsyncGenerator from sqlalchemy import text @@ -6,9 +7,20 @@ from sqlalchemy.orm import declarative_base from app.core.config import settings +logger = logging.getLogger(__name__) + +DB_POOL_CONFIG = { + "pool_pre_ping": True, + "pool_recycle": 1800, + "pool_size": 10, + "max_overflow": 20, + "pool_timeout": 30, +} + engine = create_async_engine( settings.DATABASE_URL, echo=settings.DEBUG if hasattr(settings, "DEBUG") else False, + **DB_POOL_CONFIG, ) async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -97,6 +109,16 @@ async def init_db(): import app.models.system_setting # noqa: F401 import app.models.playground_session # noqa: F401 import app.models.playground_message # noqa: F401 + import app.models.system_log # noqa: F401 + + logger.warning( + "Database pool settings active: pre_ping=%s recycle=%ss size=%s overflow=%s timeout=%ss", + DB_POOL_CONFIG["pool_pre_ping"], + DB_POOL_CONFIG["pool_recycle"], + DB_POOL_CONFIG["pool_size"], + DB_POOL_CONFIG["max_overflow"], + DB_POOL_CONFIG["pool_timeout"], + ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) diff --git a/backend/app/main.py b/backend/app/main.py index a65b77b1..3e2e83b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +from uuid import uuid4 from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -7,6 +8,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.api.main import api_router from app.api.v1 import websocket from app.core.config import settings +from app.core.request_context import set_request_id from app.core.websocket.broadcaster import broadcaster from app.db.session import init_db from app.services.scheduler import ( @@ -28,6 +30,18 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware): return await call_next(request) +class RequestContextMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + request_id = request.headers.get("X-Request-ID") or uuid4().hex + set_request_id(request_id) + try: + response = await call_next(request) + finally: + set_request_id(None) + response.headers["X-Request-ID"] = request_id + return response + + @asynccontextmanager async def lifespan(app: FastAPI): await init_db() @@ -58,6 +72,7 @@ app.add_middleware( allow_headers=["*"], ) +app.add_middleware(RequestContextMiddleware) app.add_middleware(WebSocketCORSMiddleware) app.include_router(api_router, prefix="/api/v1") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ebc4d6ca..1757eab5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -11,6 +11,7 @@ from app.models.bgp_observation import BGPObservation from app.models.system_setting import SystemSetting from app.models.playground_session import PlaygroundSession from app.models.playground_message import PlaygroundMessage +from app.models.system_log import SystemLog, AuditLog __all__ = [ "User", @@ -26,4 +27,6 @@ __all__ = [ "BGPAnomaly", "BGPIncident", "BGPObservation", + "SystemLog", + "AuditLog", ] diff --git a/backend/app/models/system_log.py b/backend/app/models/system_log.py new file mode 100644 index 00000000..5024c9b4 --- /dev/null +++ b/backend/app/models/system_log.py @@ -0,0 +1,40 @@ +from sqlalchemy import JSON, Column, DateTime, Integer, String, Text +from sqlalchemy.sql import func + +from app.db.session import Base + + +class SystemLog(Base): + __tablename__ = "system_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + source = Column(String(50), nullable=False, index=True) + service = Column(String(50), nullable=True) + module = Column(String(120), nullable=True) + event = Column(String(160), nullable=True, index=True) + level = Column(String(20), nullable=False, index=True) + message = Column(Text, nullable=False) + request_id = Column(String(64), nullable=True, index=True) + trace_id = Column(String(64), nullable=True) + user_id = Column(Integer, nullable=True, index=True) + category = Column(String(80), nullable=True, index=True) + context = Column(JSON, nullable=False, default=dict) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + occurred_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + actor_id = Column(Integer, nullable=True, index=True) + actor_name = Column(String(255), nullable=True) + action = Column(String(120), nullable=False, index=True) + target_type = Column(String(80), nullable=True) + target_id = Column(String(120), nullable=True) + result = Column(String(40), nullable=True, index=True) + request_id = Column(String(64), nullable=True, index=True) + ip = Column(String(64), nullable=True) + details = Column(JSON, nullable=False, default=dict) + created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/backend/app/services/earth_news.py b/backend/app/services/earth_news.py index 22f359a1..8de65dcb 100644 --- a/backend/app/services/earth_news.py +++ b/backend/app/services/earth_news.py @@ -30,6 +30,14 @@ class RegionProfile: accent: str +@dataclass(frozen=True) +class RegionAnchor: + region: str + label: str + latitude: float + longitude: float + + @dataclass(frozen=True) class NewsFeedSource: id: str @@ -95,6 +103,39 @@ REGION_PROFILES: dict[str, RegionProfile] = { ), } +REGION_ANCHORS: dict[str, RegionAnchor] = { + "americas": RegionAnchor( + region="americas", + label="美洲", + latitude=37.0902, + longitude=-95.7129, + ), + "europe": RegionAnchor( + region="europe", + label="欧洲", + latitude=50.1109, + longitude=8.6821, + ), + "middle-east-africa": RegionAnchor( + region="middle-east-africa", + label="中东与非洲", + latitude=25.2048, + longitude=55.2708, + ), + "asia-pacific": RegionAnchor( + region="asia-pacific", + label="亚太", + latitude=1.3521, + longitude=103.8198, + ), + "global": RegionAnchor( + region="global", + label="全球", + latitude=20.0, + longitude=0.0, + ), +} + def _google_news_feed(query: str, *, hl: str, gl: str, ceid: str) -> str: return ( @@ -213,6 +254,10 @@ def get_region_profile(region: str) -> RegionProfile: return REGION_PROFILES.get(region, REGION_PROFILES["global"]) +def get_region_anchor(region: str) -> RegionAnchor: + return REGION_ANCHORS.get(region, REGION_ANCHORS["global"]) + + def get_sources_for_region(region: str) -> list[NewsFeedSource]: return sorted( [source for source in NEWS_FEED_SOURCES if source.region in {"global", region}], @@ -342,6 +387,7 @@ def _serialize_sources(sources: list[NewsFeedSource]) -> list[dict[str, Any]]: def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, Any]: published_at = item.published_at + anchor = get_region_anchor(item.feed_region) return { "id": item.id, "title": item.title, @@ -352,6 +398,10 @@ def _serialize_item(item: ParsedNewsItem, *, active_region: str) -> dict[str, An "region": item.feed_region, "homepage_url": item.homepage_url, "published_at": published_at.isoformat().replace("+00:00", "Z") if published_at else None, + "latitude": anchor.latitude, + "longitude": anchor.longitude, + "location_label": anchor.label, + "location_inferred": True, "is_focus_match": item.feed_region == active_region, } diff --git a/backend/app/services/persistent_logs.py b/backend/app/services/persistent_logs.py new file mode 100644 index 00000000..d0f93a99 --- /dev/null +++ b/backend/app/services/persistent_logs.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import logging +from typing import Any + +from app.core.request_context import get_request_id +from app.db.session import async_session_factory +from app.models.system_log import AuditLog, SystemLog + +logger = logging.getLogger(__name__) + + +async def record_system_log( + *, + source: str, + level: str, + message: str, + service: str | None = None, + module: str | None = None, + event: str | None = None, + request_id: str | None = None, + trace_id: str | None = None, + user_id: int | None = None, + category: str | None = None, + context: dict[str, Any] | None = None, +) -> None: + try: + async with async_session_factory() as session: + session.add( + SystemLog( + source=source, + service=service, + module=module, + event=event, + level=level.lower(), + message=message, + request_id=request_id or get_request_id(), + trace_id=trace_id, + user_id=user_id, + category=category, + context=context or {}, + ) + ) + await session.commit() + except Exception: + logger.exception("Failed to persist system log event=%s source=%s", event, source) + + +async def record_audit_log( + *, + action: str, + actor_id: int | None = None, + actor_name: str | None = None, + target_type: str | None = None, + target_id: str | None = None, + result: str | None = None, + request_id: str | None = None, + ip: str | None = None, + details: dict[str, Any] | None = None, +) -> None: + try: + async with async_session_factory() as session: + session.add( + AuditLog( + actor_id=actor_id, + actor_name=actor_name, + action=action, + target_type=target_type, + target_id=target_id, + result=result, + request_id=request_id or get_request_id(), + ip=ip, + details=details or {}, + ) + ) + await session.commit() + except Exception: + logger.exception("Failed to persist audit log action=%s", action) diff --git a/backend/app/services/system_logs.py b/backend/app/services/system_logs.py new file mode 100644 index 00000000..8e404d92 --- /dev/null +++ b/backend/app/services/system_logs.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import json +import re +import shutil +import subprocess + +from collections import Counter, deque +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.core.security import redis_client + +DEFAULT_LOG_LINE_LIMIT = 200 +MAX_LOG_LINE_LIMIT = 1000 +BUFFER_LOG_LIMIT = 1000 +BUFFER_LOG_TTL_SECONDS = 7 * 24 * 60 * 60 +LOG_BUFFER_KEY_PREFIX = "planet:system_logs" + +LOG_LEVEL_ERROR = "error" +LOG_LEVEL_WARNING = "warning" +LOG_LEVEL_INFO = "info" +LOG_LEVEL_DEBUG = "debug" +LOG_LEVEL_ALL = "all" + +SUPPORTED_LOG_LEVELS = { + LOG_LEVEL_ALL, + LOG_LEVEL_ERROR, + LOG_LEVEL_WARNING, + LOG_LEVEL_INFO, + LOG_LEVEL_DEBUG, +} + +LOG_LEVEL_ALIASES = { + "warn": LOG_LEVEL_WARNING, + "warning": LOG_LEVEL_WARNING, + "err": LOG_LEVEL_ERROR, + "error": LOG_LEVEL_ERROR, + "info": LOG_LEVEL_INFO, + "information": LOG_LEVEL_INFO, + "debug": LOG_LEVEL_DEBUG, + "trace": LOG_LEVEL_DEBUG, + "critical": LOG_LEVEL_ERROR, + "fatal": LOG_LEVEL_ERROR, +} + +TIMESTAMP_FORMATS = ( + "%Y-%m-%d %H:%M:%S.%f", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", +) + +LEVEL_PATTERNS = ( + ("CRITICAL", LOG_LEVEL_ERROR), + ("FATAL", LOG_LEVEL_ERROR), + ("ERROR", LOG_LEVEL_ERROR), + ("WARNING", LOG_LEVEL_WARNING), + ("WARN", LOG_LEVEL_WARNING), + ("INFO", LOG_LEVEL_INFO), + ("DEBUG", LOG_LEVEL_DEBUG), + ("TRACE", LOG_LEVEL_DEBUG), +) + +LEADING_LEVEL_PATTERN = re.compile( + r"^\s*(?:\[[^\]]+\]\s*)?(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b[:\s-]*", + re.IGNORECASE, +) +EMBEDDED_LEVEL_PATTERN = re.compile( + r"\b(CRITICAL|FATAL|ERROR|WARNING|WARN|INFO|DEBUG|TRACE)\b", + re.IGNORECASE, +) + + +@dataclass(frozen=True) +class LogSource: + source_id: str + name: str + kind: str + location: str + description: str + category: str + status: str = "ok" + buffer_key: str | None = None + container_name: str | None = None + + +@dataclass +class StructuredLogEntry: + timestamp: datetime | None + level: str | None + display_line: str + raw_line: str + search_text: str + + +@dataclass +class DailyLogMarker: + date_token: str + total: int + dominant_level: str + + +LOG_SOURCES: dict[str, LogSource] = { + "backend": LogSource( + source_id="backend", + name="后端服务", + kind="file", + location="/tmp/planet_backend.log", + description="FastAPI 后端、调度器和采集任务共享日志。", + category="service", + ), + "frontend": LogSource( + source_id="frontend", + name="前端开发服务", + kind="file", + location="/tmp/planet_frontend.log", + description="控制台与 Earth 前端开发服务输出。", + category="service", + ), + "ai-provider": LogSource( + source_id="ai-provider", + name="AI Provider", + kind="docker", + location="docker://planet_aiprovider", + description="AI Provider 容器实时输出日志。", + category="service", + container_name="planet_aiprovider", + ), + "earth-client": LogSource( + source_id="earth-client", + name="Earth 浏览器端", + kind="buffer", + location="redis://planet:system_logs:earth-client", + description="Earth 浏览器端上报的运行时错误与关键业务日志。", + category="client", + buffer_key=f"{LOG_BUFFER_KEY_PREFIX}:earth-client", + ), +} + + +def normalize_log_level(level: str | None) -> str: + if level is None: + return LOG_LEVEL_ALL + normalized = str(level).strip().lower() + if normalized in {"", LOG_LEVEL_ALL}: + return LOG_LEVEL_ALL + return LOG_LEVEL_ALIASES.get(normalized, LOG_LEVEL_ALL) + + +def normalize_log_levels(level: str | None = None, levels: str | None = None) -> tuple[str, ...]: + normalized_levels: list[str] = [] + if levels: + for item in str(levels).split(","): + normalized = normalize_log_level(item) + if normalized != LOG_LEVEL_ALL and normalized not in normalized_levels: + normalized_levels.append(normalized) + normalized_level = normalize_log_level(level) + if normalized_level != LOG_LEVEL_ALL and normalized_level not in normalized_levels: + normalized_levels.append(normalized_level) + return tuple(normalized_levels) + + +def get_source_status(source: LogSource) -> str: + if source.kind == "file": + path = Path(source.location) + if not path.exists(): + return "missing" + return "ok" if path.stat().st_size > 0 else "empty" + if source.kind == "docker": + return "ok" if shutil.which("docker") else "docker_unavailable" + if source.kind == "buffer": + if not source.buffer_key: + return "source_unavailable" + try: + return "ok" if redis_client.llen(source.buffer_key) > 0 else "empty" + except Exception: + return "source_unavailable" + return "source_unavailable" + + +def list_log_sources() -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + for source in LOG_SOURCES.values(): + items.append( + { + "source_id": source.source_id, + "name": source.name, + "kind": source.kind, + "location": source.location, + "description": source.description, + "category": source.category, + "status": get_source_status(source), + } + ) + return items + + +def get_buffer_log_key(source_id: str) -> str: + return f"{LOG_BUFFER_KEY_PREFIX}:{source_id}" + + +def append_buffer_log( + source_id: str, + *, + level: str, + message: str, + context: dict[str, Any] | None = None, +) -> None: + payload = { + "timestamp": datetime.now(tz=UTC).isoformat(), + "level": normalize_log_level(level), + "message": message, + "context": context or {}, + } + buffer_key = get_buffer_log_key(source_id) + redis_client.rpush(buffer_key, json.dumps(payload, ensure_ascii=False)) + redis_client.ltrim(buffer_key, -BUFFER_LOG_LIMIT, -1) + redis_client.expire(buffer_key, BUFFER_LOG_TTL_SECONDS) + + +def parse_timestamp(raw_value: str | None) -> datetime | None: + if not raw_value: + return None + candidate = str(raw_value).strip() + if not candidate: + return None + candidate = candidate.replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(candidate) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + except ValueError: + pass + for fmt in TIMESTAMP_FORMATS: + try: + return datetime.strptime(candidate, fmt).replace(tzinfo=UTC) + except ValueError: + continue + return None + + +def parse_prefixed_timestamp(line: str) -> tuple[datetime | None, str]: + stripped = line.strip() + if not stripped: + return None, "" + for prefix_length in (35, 32, 29, 26, 23, 19): + if len(stripped) < prefix_length: + continue + prefix = stripped[:prefix_length] + timestamp = parse_timestamp(prefix) + if timestamp is not None: + return timestamp, stripped[prefix_length:].lstrip() + first_token = stripped.split(maxsplit=1)[0] + timestamp = parse_timestamp(first_token) + if timestamp is not None: + remainder = stripped[len(first_token):].lstrip() + return timestamp, remainder + return None, stripped + + +def infer_log_level_from_text(text: str) -> str | None: + leading_match = LEADING_LEVEL_PATTERN.match(text) + if leading_match: + return normalize_log_level(leading_match.group(1)) + + embedded_match = EMBEDDED_LEVEL_PATTERN.search(text) + if embedded_match: + return normalize_log_level(embedded_match.group(1)) + + upper_text = text.upper() + for pattern, normalized in LEVEL_PATTERNS: + if f"{pattern}:" in upper_text or f"{pattern} " in upper_text: + return normalized + return None + + +def build_display_line(timestamp: datetime | None, level: str | None, message: str) -> str: + message_part = message.strip() if message else "" + parts = [] + if timestamp is not None: + parts.append(timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S")) + if level: + parts.append(level.upper()) + if message_part: + parts.append(message_part) + return " ".join(parts).strip() + + +def parse_text_log_entry(line: str) -> StructuredLogEntry: + timestamp, remainder = parse_prefixed_timestamp(line) + level = infer_log_level_from_text(remainder or line) + display_line = line.rstrip("\n") + return StructuredLogEntry( + timestamp=timestamp, + level=level, + display_line=display_line, + raw_line=display_line, + search_text=display_line.lower(), + ) + + +def build_buffer_entry(payload: dict[str, Any]) -> StructuredLogEntry: + timestamp = parse_timestamp(str(payload.get("timestamp", "")).strip()) + level = normalize_log_level(payload.get("level")) + if level == LOG_LEVEL_ALL: + level = None + message = str(payload.get("message", "")).strip() + context = payload.get("context") + context_map = context if isinstance(context, dict) else {} + context_fragments = [] + for key in ("category", "module", "url", "detail"): + value = str(context_map.get(key, "")).strip() + if value: + context_fragments.append(f"{key}={value}") + message_with_context = " | ".join([message, *context_fragments]) if context_fragments else message + display_line = build_display_line(timestamp, level, message_with_context) + search_text = " ".join( + [ + message, + json.dumps(context_map, ensure_ascii=False, sort_keys=True), + display_line, + ] + ).lower() + return StructuredLogEntry( + timestamp=timestamp, + level=level, + display_line=display_line, + raw_line=json.dumps(payload, ensure_ascii=False, sort_keys=True), + search_text=search_text, + ) + + +def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]: + path = Path(source.location) + if not path.exists(): + return [] + with path.open("r", encoding="utf-8", errors="replace") as handle: + recent_lines = deque(handle, maxlen=scan_limit) + return [parse_text_log_entry(line) for line in recent_lines if line.strip()] + + +def read_docker_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]: + if not shutil.which("docker") or not source.container_name: + return [] + try: + completed = subprocess.run( + [ + "docker", + "logs", + "--timestamps", + "--tail", + str(scan_limit), + source.container_name, + ], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return [] + if completed.returncode != 0: + return [] + return [ + parse_text_log_entry(line) + for line in completed.stdout.splitlines() + if line.strip() + ] + + +def read_buffer_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]: + if not source.buffer_key: + return [] + try: + raw_items = redis_client.lrange(source.buffer_key, -scan_limit, -1) + except Exception: + return [] + entries: list[StructuredLogEntry] = [] + for raw_item in raw_items: + try: + payload = json.loads(raw_item) + except json.JSONDecodeError: + entries.append(parse_text_log_entry(str(raw_item))) + continue + if isinstance(payload, dict): + entries.append(build_buffer_entry(payload)) + else: + entries.append(parse_text_log_entry(str(raw_item))) + return entries + + +def read_source_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]: + if source.kind == "file": + return read_file_entries(source, scan_limit) + if source.kind == "docker": + return read_docker_entries(source, scan_limit) + if source.kind == "buffer": + return read_buffer_entries(source, scan_limit) + return [] + + +def matches_levels(entry: StructuredLogEntry, selected_levels: tuple[str, ...]) -> bool: + if not selected_levels: + return True + return entry.level in selected_levels + + +def matches_date_range( + entry: StructuredLogEntry, + start_date: str | None, + end_date: str | None, +) -> bool: + if not start_date and not end_date: + return True + if entry.timestamp is None: + return False + date_token = entry.timestamp.astimezone(UTC).date().isoformat() + if start_date and date_token < start_date: + return False + if end_date and date_token > end_date: + return False + return True + + +def matches_search(entry: StructuredLogEntry, search: str | None) -> bool: + if search is None: + return True + query = search.strip().lower() + if not query: + return True + return query in entry.search_text + + +def build_daily_log_markers(entries: list[StructuredLogEntry]) -> list[dict[str, Any]]: + grouped: dict[str, list[StructuredLogEntry]] = {} + for entry in entries: + if entry.timestamp is None: + continue + date_token = entry.timestamp.astimezone(UTC).date().isoformat() + grouped.setdefault(date_token, []).append(entry) + + markers: list[DailyLogMarker] = [] + for date_token, group in sorted(grouped.items()): + level_counts = Counter( + entry.level + for entry in group + if entry.level in SUPPORTED_LOG_LEVELS and entry.level != LOG_LEVEL_ALL + ) + dominant_level = LOG_LEVEL_INFO + if level_counts: + dominant_level = sorted( + level_counts.items(), + key=lambda item: ( + -item[1], + ("error", "warning", "info", "debug").index(item[0]), + ), + )[0][0] + markers.append( + DailyLogMarker( + date_token=date_token, + total=len(group), + dominant_level=dominant_level, + ) + ) + return [marker.__dict__ for marker in markers] + + +def read_log_snapshot( + source_id: str, + limit: int, + *, + level: str = LOG_LEVEL_ALL, + levels: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + search: str | None = None, +) -> dict[str, Any] | None: + source = LOG_SOURCES.get(source_id) + if source is None: + return None + + selected_levels = normalize_log_levels(level, levels) + search_query = (search or "").strip() + scan_limit = max(min(MAX_LOG_LINE_LIMIT * 5, 5000), limit * 5, BUFFER_LOG_LIMIT if source.kind == "buffer" else 1000) + all_entries = read_source_entries(source, scan_limit) + marker_entries = [ + entry + for entry in all_entries + if matches_levels(entry, selected_levels) and matches_search(entry, search_query) + ] + filtered_entries = [ + entry + for entry in marker_entries + if matches_date_range(entry, start_date, end_date) + ] + visible_entries = filtered_entries[-limit:] + + compatibility_level = selected_levels[0] if len(selected_levels) == 1 else LOG_LEVEL_ALL + return { + "source_id": source.source_id, + "name": source.name, + "kind": source.kind, + "location": source.location, + "description": source.description, + "category": source.category, + "status": get_source_status(source), + "level": compatibility_level, + "selected_levels": list(selected_levels), + "search_query": search_query, + "available_levels": [ + LOG_LEVEL_ALL, + LOG_LEVEL_ERROR, + LOG_LEVEL_WARNING, + LOG_LEVEL_INFO, + LOG_LEVEL_DEBUG, + ], + "daily_markers": build_daily_log_markers(marker_entries), + "line_limit": limit, + "line_count": len(visible_entries), + "lines": [entry.display_line for entry in visible_entries], + } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 2822bf82..d917ab6a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -35,6 +35,7 @@ async def test_health_check(): data = response.json() assert data["status"] == "healthy" assert "version" in data + assert response.headers["x-request-id"] @pytest.mark.asyncio @@ -161,6 +162,345 @@ async def test_alerts_endpoint_with_auth(auth_headers): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_system_log_sources_requires_super_admin(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="testuser", + email="test@example.com", + password_hash="hashed", + role="admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/system/logs/sources", headers=auth_headers) + assert response.status_code == 403 + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_system_log_sources_with_super_admin(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.system_control.list_log_sources", + return_value=[ + { + "source_id": "backend", + "name": "后端服务", + "kind": "file", + "location": "/tmp/planet_backend.log", + "description": "FastAPI 后端、调度器和采集任务共享日志。", + "category": "service", + "status": "ok", + } + ], + ): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/system/logs/sources", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["items"][0]["source_id"] == "backend" + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_system_log_snapshot_with_super_admin(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.system_control.read_log_snapshot", + return_value={ + "source_id": "backend", + "name": "后端服务", + "kind": "file", + "location": "/tmp/planet_backend.log", + "description": "FastAPI 后端、调度器和采集任务共享日志。", + "category": "service", + "status": "ok", + "level": "all", + "selected_levels": [], + "search_query": "", + "available_levels": ["all", "error", "warning", "info", "debug"], + "daily_markers": [], + "line_limit": 50, + "line_count": 2, + "lines": ["line 1", "line 2"], + }, + ): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/system/logs/backend?limit=50", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["source_id"] == "backend" + assert data["line_count"] == 2 + assert data["lines"] == ["line 1", "line 2"] + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_system_log_snapshot_supports_level_filter(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.system_control.read_log_snapshot", + return_value={ + "source_id": "backend", + "name": "后端服务", + "kind": "file", + "location": "/tmp/planet_backend.log", + "description": "FastAPI 后端、调度器和采集任务共享日志。", + "category": "service", + "status": "ok", + "level": "error", + "selected_levels": ["error"], + "search_query": "", + "available_levels": ["all", "error", "warning", "info", "debug"], + "daily_markers": [], + "line_limit": 50, + "line_count": 1, + "lines": ["ERROR: failed"], + }, + ): + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/api/v1/system/logs/backend?limit=50&level=error", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["level"] == "error" + assert data["lines"] == ["ERROR: failed"] + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_system_log_snapshot_supports_date_range_filter(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.system_control.read_log_snapshot", + return_value={ + "source_id": "backend", + "name": "后端服务", + "kind": "file", + "location": "/tmp/planet_backend.log", + "description": "FastAPI 后端、调度器和采集任务共享日志。", + "category": "service", + "status": "ok", + "level": "all", + "selected_levels": [], + "search_query": "", + "available_levels": ["all", "error", "warning", "info", "debug"], + "daily_markers": [], + "line_limit": 50, + "line_count": 1, + "lines": ["2026-04-23 INFO: service started"], + }, + ) as mock_read_log_snapshot: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/system/logs/backend?limit=50&start_date=2026-04-20&end_date=2026-04-23", + headers=auth_headers, + ) + assert response.status_code == 200 + mock_read_log_snapshot.assert_called_once_with( + "backend", + 50, + level="all", + levels=None, + start_date="2026-04-20", + end_date="2026-04-23", + search=None, + ) + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_system_log_snapshot_supports_levels_and_search_filter(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + with patch( + "app.api.v1.system_control.read_log_snapshot", + return_value={ + "source_id": "backend", + "name": "后端服务", + "kind": "file", + "location": "/tmp/planet_backend.log", + "description": "FastAPI 后端、调度器和采集任务共享日志。", + "category": "service", + "status": "ok", + "level": "all", + "selected_levels": ["error", "warning"], + "search_query": "timeout", + "available_levels": ["all", "error", "warning", "info", "debug"], + "daily_markers": [], + "line_limit": 50, + "line_count": 1, + "lines": ["2026-04-23 10:00:00 ERROR timeout"], + }, + ) as mock_read_log_snapshot: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/system/logs/backend?limit=50&levels=error,warning&search=timeout", + headers=auth_headers, + ) + assert response.status_code == 200 + mock_read_log_snapshot.assert_called_once_with( + "backend", + 50, + level="all", + levels="error,warning", + start_date=None, + end_date=None, + search="timeout", + ) + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_system_log_snapshot_rejects_invalid_date_range(auth_headers): + def override_get_current_user(): + return User( + id=1, + username="root", + email="root@example.com", + password_hash="hashed", + role="super_admin", + is_active=True, + ) + + app.dependency_overrides = { + __import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user, + } + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get( + "/api/v1/system/logs/backend?start_date=2026-04-31", + headers=auth_headers, + ) + assert response.status_code == 400 + assert "start_date must be in YYYY-MM-DD format" in response.json()["detail"] + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_ingest_earth_client_log_accepts_public_events(): + transport = ASGITransport(app=app) + try: + with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log: + with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log: + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/api/v1/system/logs/earth-client", + json={ + "level": "error", + "message": "登陆点加载失败: 登陆点接口返回 HTTP 500", + "category": "startup-load", + "module": "layer-startup", + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["accepted"] is True + assert data["source_id"] == "earth-client" + mock_append_buffer_log.assert_called_once() + mock_record_system_log.assert_awaited_once() + persisted_kwargs = mock_record_system_log.await_args.kwargs + assert persisted_kwargs["source"] == "earth-client" + assert persisted_kwargs["event"] == "earth.client.runtime_log" + assert persisted_kwargs["category"] == "startup-load" + assert persisted_kwargs["level"] == "error" + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_request_id_header_is_echoed_when_provided(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/health", headers={"X-Request-ID": "planet-test-request"}) + assert response.status_code == 200 + assert response.headers["x-request-id"] == "planet-test-request" + + @pytest.mark.asyncio async def test_invalid_token(): """Test that invalid token is rejected""" @@ -263,6 +603,8 @@ async def test_ai_situational_analysis_returns_503_when_disabled(auth_headers): assert "content_blocks" in data assert "text_blocks" in data assert "thinking_blocks" in data + finally: + app.dependency_overrides.clear() @pytest.mark.asyncio @@ -382,8 +724,6 @@ async def test_save_playground_session_with_auth(auth_headers): assert data["state"]["objective"] == "测试目标" finally: app.dependency_overrides.clear() - finally: - app.dependency_overrides.clear() @pytest.mark.asyncio diff --git a/backend/tests/test_earth_news.py b/backend/tests/test_earth_news.py new file mode 100644 index 00000000..83661b0c --- /dev/null +++ b/backend/tests/test_earth_news.py @@ -0,0 +1,49 @@ +from datetime import UTC, datetime + +from app.services.earth_news import ParsedNewsItem, _serialize_item + + +def test_serialize_item_includes_region_anchor_for_cruise(): + item = ParsedNewsItem( + id="google-apac:test", + title="Example APAC story", + summary="Example summary", + url="https://example.com/story", + source="Example Source", + feed_name="Global Monitor / APAC", + feed_region="asia-pacific", + homepage_url="https://example.com", + published_at=datetime(2026, 4, 23, 2, 30, tzinfo=UTC), + ) + + payload = _serialize_item(item, active_region="asia-pacific") + + assert payload["latitude"] == 1.3521 + assert payload["longitude"] == 103.8198 + assert payload["location_label"] == "亚太" + assert payload["location_inferred"] is True + assert payload["is_focus_match"] is True + assert payload["published_at"] == "2026-04-23T02:30:00Z" + + +def test_serialize_item_falls_back_to_global_anchor(): + item = ParsedNewsItem( + id="custom:test", + title="Fallback story", + summary="Fallback summary", + url="https://example.com/fallback", + source="Fallback Source", + feed_name="Fallback Feed", + feed_region="unknown-region", + homepage_url="https://example.com", + published_at=None, + ) + + payload = _serialize_item(item, active_region="americas") + + assert payload["latitude"] == 20.0 + assert payload["longitude"] == 0.0 + assert payload["location_label"] == "全球" + assert payload["location_inferred"] is True + assert payload["is_focus_match"] is False + assert payload["published_at"] is None diff --git a/backend/tests/test_system_logs.py b/backend/tests/test_system_logs.py new file mode 100644 index 00000000..eb1b5efa --- /dev/null +++ b/backend/tests/test_system_logs.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json + +from pathlib import Path + +from app.services import system_logs + + +class FakeRedis: + def __init__(self) -> None: + self.store: dict[str, list[str]] = {} + + def rpush(self, key: str, value: str) -> None: + self.store.setdefault(key, []).append(value) + + def ltrim(self, key: str, start: int, end: int) -> None: + items = self.store.get(key, []) + normalized_end = None if end == -1 else end + 1 + self.store[key] = items[start:normalized_end] + + def expire(self, key: str, seconds: int) -> None: + return None + + def lrange(self, key: str, start: int, end: int) -> list[str]: + items = self.store.get(key, []) + normalized_end = None if end == -1 else end + 1 + return items[start:normalized_end] + + def llen(self, key: str) -> int: + return len(self.store.get(key, [])) + + +def test_read_log_snapshot_uses_structured_buffer_timestamp_level_and_search(monkeypatch): + fake_redis = FakeRedis() + monkeypatch.setattr(system_logs, "redis_client", fake_redis) + monkeypatch.setattr( + system_logs, + "LOG_SOURCES", + { + "earth-client": system_logs.LogSource( + source_id="earth-client", + name="Earth 浏览器端", + kind="buffer", + location="redis://planet:system_logs:earth-client", + description="Earth 浏览器端上报日志", + category="client", + buffer_key=system_logs.get_buffer_log_key("earth-client"), + ) + }, + ) + + fake_redis.rpush( + system_logs.get_buffer_log_key("earth-client"), + json.dumps( + { + "timestamp": "2026-04-22T10:15:30Z", + "level": "warning", + "message": "news feed degraded", + "context": {"module": "news", "detail": "timeout"}, + }, + ensure_ascii=False, + ), + ) + fake_redis.rpush( + system_logs.get_buffer_log_key("earth-client"), + json.dumps( + { + "timestamp": "2026-04-23T06:01:00Z", + "level": "error", + "message": "landing points failed", + "context": {"module": "layer-startup", "detail": "http 500"}, + }, + ensure_ascii=False, + ), + ) + + snapshot = system_logs.read_log_snapshot( + "earth-client", + 50, + levels="error,warning", + start_date="2026-04-23", + end_date="2026-04-23", + search="landing", + ) + + assert snapshot is not None + assert snapshot["selected_levels"] == ["error", "warning"] + assert snapshot["search_query"] == "landing" + assert snapshot["line_count"] == 1 + assert snapshot["lines"][0].startswith("2026-04-23 06:01:00 ERROR landing points failed") + assert snapshot["daily_markers"] == [ + {"date_token": "2026-04-23", "total": 1, "dominant_level": "error"} + ] + + +def test_read_log_snapshot_parses_file_timestamp_and_builds_markers(tmp_path: Path, monkeypatch): + log_path = tmp_path / "backend.log" + log_path.write_text( + "\n".join( + [ + "2026-04-22 08:00:00 INFO service booted", + "2026-04-23 09:15:00 WARNING disk pressure detected", + "2026-04-23 09:16:00 ERROR sync failed", + "2026-04-24 10:00:00 DEBUG collector trace", + ] + ), + encoding="utf-8", + ) + + monkeypatch.setattr( + system_logs, + "LOG_SOURCES", + { + "backend": system_logs.LogSource( + source_id="backend", + name="后端服务", + kind="file", + location=str(log_path), + description="测试文件日志", + category="service", + ) + }, + ) + + snapshot = system_logs.read_log_snapshot( + "backend", + 50, + levels="warning,error", + search="failed", + ) + + assert snapshot is not None + assert snapshot["line_count"] == 1 + assert snapshot["lines"] == ["2026-04-23 09:16:00 ERROR sync failed"] + assert snapshot["daily_markers"] == [ + {"date_token": "2026-04-23", "total": 1, "dominant_level": "error"} + ] + assert snapshot["status"] == "ok" + + +def test_append_buffer_log_persists_normalized_level(monkeypatch): + fake_redis = FakeRedis() + monkeypatch.setattr(system_logs, "redis_client", fake_redis) + + system_logs.append_buffer_log( + "earth-client", + level="warn", + message="feed delayed", + context={"module": "news"}, + ) + + stored_items = fake_redis.lrange(system_logs.get_buffer_log_key("earth-client"), 0, -1) + payload = json.loads(stored_items[0]) + assert payload["level"] == "warning" + assert payload["message"] == "feed delayed" + + +def test_infer_log_level_prefers_leading_prefix_over_query_string(): + line = 'INFO: 127.0.0.1 - "GET /api/v1/system/logs/backend?limit=200&level=error&levels=error HTTP/1.1" 200 OK' + + entry = system_logs.parse_text_log_entry(line) + + assert entry.level == "info" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5d4a5f73..2fb726cf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,24 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.38.0] — 2026-04-23 + +### ✨ Highlights +- Earth 新闻正式接入通用巡航层:新闻和 BGP 统一进入可配置巡航模块,桌面端与移动端都能在巡航聚焦时展示对应新闻卡片 +- 系统日志页升级为结构化过滤链路:按真实时间戳、结构化级别和字符串检索统一筛选,不再依赖前端或后端从日志文本里猜结果 + +### 🔧 Improvements +- 新闻巡航补齐业务适配层:按发生地与时间生成巡航目标,桌面端与移动端统一标题 + summary 卡片风格,并增加连线与打字机摘要展示 +- 日志页筛选体验重排,统一服务源、级别、行数、时间和检索布局,日历标记改为由后端返回的结构化每日聚合结果驱动 +- 后端补充 `system_logs` 结构化解析与多级别精确过滤能力,Earth 浏览器端日志缓冲与系统日志 API 现在走同一套筛选语义 + +### 🐛 Fixes +- 修复新闻巡航模块开启后难以关闭、桌面/移动端设置状态互相污染的问题 +- 修复新闻巡航卡片缺少摘要、移动端详情样式不统一、新闻巡航缺少连线的问题 +- 修复日志级别筛选会被访问日志 query string 中的 `level=error` 等参数污染,从而把 `INFO` 行误判为 `ERROR` 的问题 + +--- + ## [0.37.2] — 2026-04-23 ### ✨ Highlights diff --git a/docs/plans/enterprise-logging-system-plan.md b/docs/plans/enterprise-logging-system-plan.md new file mode 100644 index 00000000..35a91394 --- /dev/null +++ b/docs/plans/enterprise-logging-system-plan.md @@ -0,0 +1,665 @@ +# Planet 企业级日志系统规范与落地计划 + +## 目标 + +为 Planet 建立一套可持续演进的日志体系,覆盖: + +1. 后端运行日志 +2. 前端浏览器端错误与关键业务日志 +3. 超级管理员操作审计 +4. 高价值事件持久化 +5. 实时排障与历史追溯并存 + +最终目标不是“把所有输出都收进一个页面”,而是建立: + +- 统一日志字段规范 +- 统一事件命名规范 +- 统一采集与查询链路 +- 清晰的实时日志层与持久化事件层分工 + +## 当前现状 + +项目当前已经具备一部分基础: + +- 后端日志可从 `/tmp/planet_backend.log` 查看 +- 前端开发服务日志可从 `/tmp/planet_frontend.log` 查看 +- AI Provider 日志可从 Docker 容器读取 +- Earth 浏览器端错误可通过 API 上报并进入 Redis 缓冲 +- 管理台已有“系统日志”页面,可做来源、级别、日期等筛选 + +### 当前落地进度 + +截至 2026-04-23,第一阶段已经落地的能力有: + +- 后端新增 `request_id` 中间件,响应会回传 `X-Request-ID` +- 新增 `system_logs` / `audit_logs` 数据表模型并接入初始化流程 +- Earth 浏览器端上报日志已同时写入缓冲层和 `system_logs` +- `landing-points` / `cables` 关键后端异常已写入 `system_logs` +- 超级管理员触发重启任务时会写入 `audit_logs` + +当前仍未完成的部分: + +- 后端统一结构化 logger 封装还没有全仓替换 +- 前端统一 logger API 还没有扩展到整个控制台 +- 日志页还没有提供“历史事件库”查询视图 +- 采集器与调度器的关键日志还没有系统性入库 + +当前主要问题: + +- 日志不是统一规范打点,很多地方仍然是临时性输出 +- 后端没有统一 request/trace 相关字段 +- 前端没有统一 logger API,Earth 端虽然已能上报,但仍偏点状能力 +- 当前系统日志页以聚合查看为主,还不是企业级日志架构 +- 日志历史追溯能力不足,尤其 Earth 客户端和关键业务失败事件 +- 审计日志与运行日志还没有严格分层 + +## 设计原则 + +### 1. 分层而不是混存 + +日志分为三层: + +1. 运行日志 +- 面向排障、运维、链路观察 +- 默认不直接写业务数据库 +- 主要走 stdout / 文件 / 容器 / 日志平台 + +2. 高价值事件日志 +- 面向历史追溯和业务排查 +- 只持久化 error、warning 和关键业务失败 +- 允许写数据库 + +3. 审计日志 +- 面向管理行为留痕 +- 单独建模 +- 不和普通运行日志混用 + +### 2. 结构化优先 + +所有正式日志都应能拆成字段,而不是只有一句字符串。 + +### 3. 平台采集优先于业务数据库 + +全量日志走日志平台。 + +数据库只存: + +- 高价值错误事件 +- 关键业务失败事件 +- 审计事件 + +### 4. 前后端统一事件语言 + +同一个问题在前端和后端应尽量共享事件命名。 + +例如: + +- `earth.landing_points.load_failed` +- `earth.news.feed.refresh_failed` +- `system.restart_task.failed` + +这样在页面、API、数据库、日志平台里都能串联查询。 + +### 5. 默认脱敏 + +日志禁止记录: + +- token +- password +- cookie +- Authorization header +- 完整敏感 PII + +## 日志分层规范 + +## 一、后端运行日志规范 + +### 使用方式 + +- 统一使用 Python `logging` +- 禁止在正式路径中使用裸 `print` +- 统一 `logger = logging.getLogger(__name__)` + +### 最低字段要求 + +后端正式日志至少应能携带: + +- `timestamp` +- `level` +- `service` +- `module` +- `event` +- `message` +- `request_id` +- `trace_id` +- `user_id` 或 `actor` +- `context` + +### 等级定义 + +- `DEBUG` + 仅开发或短期诊断使用 +- `INFO` + 关键流程开始、结束、状态切换 +- `WARNING` + 可恢复异常、降级、重试、部分失败 +- `ERROR` + 当前请求、任务或操作失败 +- `CRITICAL` + 系统级不可用、核心能力中断 + +### 推荐记录点 + +必须补日志的位置: + +- API 入口请求摘要 +- API 异常出口 +- 定时任务启动/完成/失败 +- 数据采集器启动/完成/失败 +- 外部依赖失败 +- 关键 Earth 业务 API 失败 + +推荐模式: + +```python +logger.info( + "collector started", + extra={ + "event": "collector.run.started", + "source": source_name, + "task_id": task_id, + }, +) +``` + +异常必须优先使用: + +```python +logger.exception("landing points build failed", extra={"event": "earth.landing_points.load_failed"}) +``` + +## 二、前端日志规范 + +### 前端日志分级 + +前端不做“全量 console 上报”,而做三层: + +1. 本地调试日志 +- 保留在浏览器 console +- 不上报 + +2. 运行时错误 +- `window.onerror` +- `unhandledrejection` +- React/Earth 模块未捕获异常 +- 上报到后端日志入口 + +3. 关键业务事件 +- 接口加载失败 +- 图层初始化失败 +- 巡航队列构建失败 +- 页面关键模块进入降级状态 + +### 前端 logger API 建议 + +统一设计为: + +```ts +logger.debug(event, message, context?) +logger.info(event, message, context?) +logger.warn(event, message, context?) +logger.error(event, message, context?) +``` + +最低字段要求: + +- `timestamp` +- `level` +- `page` +- `module` +- `event` +- `message` +- `url` +- `user_agent` +- `context` + +### 前端上报范围建议 + +默认上报: + +- `ERROR` +- `WARNING` +- 关键业务失败 `INFO` + +默认不上报: + +- 调试型 `DEBUG` +- 普通开发 `console.log` + +## 三、审计日志规范 + +审计日志单独设计,不和系统运行日志混合。 + +### 适用范围 + +- 系统重启 +- 配置变更 +- 数据源启停与优先级调整 +- 调度策略变更 +- 管理员触发采集任务 +- 高权限操作 + +### 最低字段要求 + +- `timestamp` +- `actor_id` +- `actor_name` +- `action` +- `target_type` +- `target_id` +- `result` +- `ip` +- `request_id` +- `details` + +## 统一事件命名规范 + +建议命名采用: + +`...` + +示例: + +- `earth.landing_points.load_failed` +- `earth.news.feed.refreshed` +- `earth.news.cruise_queue.built` +- `system.logs.snapshot_requested` +- `system.restart_task.started` +- `system.restart_task.failed` +- `datasource.collector.run_failed` +- `settings.system.updated` + +规则: + +- domain 使用稳定业务域 +- module 指实际模块 +- action 使用动词 +- result 使用过去时或结果词 + +## 企业级目标架构 + +推荐采用“双轨架构”: + +1. 实时运行日志轨 +2. 高价值持久化事件轨 + +```mermaid +flowchart LR + A["Frontend / Earth"] --> B["Frontend Logger"] + C["Backend API / Scheduler / Collectors"] --> D["Backend Logger"] + B --> E["Log Ingest API"] + D --> F["stdout / file / docker logs"] + F --> G["Log Collector"] + G --> H["Log Platform (Loki / ELK / Datadog)"] + E --> I["High-value Event Filter"] + D --> I + I --> J["PostgreSQL system_logs / audit_logs"] + H --> K["Ops Search / Alerting"] + J --> L["Admin Logs UI / History Query"] +``` + +### 实时运行日志层 + +职责: + +- 低延迟排障 +- 实时观察 +- 全文检索 +- 告警触发 + +推荐落地: + +- 开发期:文件 + Docker + 管理台聚合查看 +- 标准化阶段:Fluent Bit / Vector -> Loki 或 ELK + +### 高价值事件层 + +职责: + +- 长期追溯 +- 按业务事件检索 +- 与产品页面、管理台联动 + +推荐落地: + +- PostgreSQL `system_logs` +- PostgreSQL `audit_logs` + +## 数据库设计建议 + +## 一、`system_logs` + +只存高价值运行事件,不存全量流水。 + +建议字段: + +- `id` +- `occurred_at` +- `source` +- `service` +- `module` +- `event` +- `level` +- `message` +- `request_id` +- `trace_id` +- `user_id` +- `category` +- `context` +- `retention_class` +- `created_at` + +### 典型 source + +- `backend` +- `frontend` +- `earth-client` +- `scheduler` +- `collector` +- `ai-provider` + +### 典型 retention_class + +- `short_term` +- `incident` +- `audit_linked` + +## 二、`audit_logs` + +建议字段: + +- `id` +- `occurred_at` +- `actor_id` +- `actor_name` +- `action` +- `target_type` +- `target_id` +- `result` +- `request_id` +- `ip` +- `details` +- `created_at` + +## 系统日志页面演进目标 + +当前日志页已经有基础能力,但企业级目标应拆成两个视图: + +1. 实时日志视图 +- 来源 +- 级别 +- 日期范围 +- 实时刷新 +- 原始日志查看 + +2. 历史事件视图 +- 查询 `system_logs` +- 查询 `audit_logs` +- 支持按事件名、来源、级别、时间范围、用户筛选 + +不建议让同一个视图同时承担: + +- 全量运行日志 +- 审计日志 +- 业务事件历史 + +推荐分 Tab 或分页面。 + +## 分阶段落地计划 + +## 第一阶段:统一规范与最小治理 + +### 目标 + +把当前零散日志行为统一起来,为后续平台化做准备。 + +### 任务 + +1. 后端统一 logger 入口 +- 清理裸 `print` +- 补齐关键异常 `logger.exception` +- 统一关键 event 名称 + +2. 前端统一 logger API +- 为 Earth 和管理台提供统一日志封装 +- 收敛浏览器端错误上报 + +3. 日志字段规范文档落地 +- 在仓库中固定字段、事件命名、级别约定 + +### 验收标准 + +- 后端关键失败路径不再依赖 `print` +- Earth 端关键失败通过统一 API 上报 +- 新代码使用统一 event 命名 + +## 第二阶段:上下文打通 + +### 目标 + +让前后端日志可串联。 + +### 任务 + +1. 后端增加 `request_id` +- 中间件生成并注入 +- 响应头回传 + +2. 前端请求链带上 `request_id` +- 或至少在错误展示中保留后端返回 request id + +3. 关键接口补 `trace` 相关上下文 + +### 验收标准 + +- 单个失败请求可以从前端提示一路查到后端日志 +- 系统日志页可展示 request id 或关联字段 + +## 第三阶段:高价值事件入库 + +### 目标 + +建立真正的历史追溯能力。 + +### 任务 + +1. 新增 `system_logs` 表 +2. 新增 `audit_logs` 表 +3. 持久化以下内容: +- Earth 客户端错误 +- 后端 `ERROR/WARNING` +- 关键业务失败事件 +- 超级管理员操作审计 + +4. 管理台增加历史事件查询 + +### 验收标准 + +- 服务重启后仍能查到关键错误 +- Earth 侧错误不依赖 Redis TTL 才能追踪 +- 管理员关键操作有审计记录 + +## 第四阶段:日志平台接入 + +### 目标 + +把全量运行日志从“页面聚合查看”升级为标准日志平台。 + +### 推荐技术路线 + +可选方案 A: + +- Fluent Bit +- Loki +- Grafana + +可选方案 B: + +- Filebeat +- Elasticsearch +- Kibana + +### 任务 + +1. 统一 stdout / file / docker 输出接入 collector +2. 接入集中日志平台 +3. 配置基础检索与告警规则 + +### 验收标准 + +- 可按 service / level / event / request_id 检索 +- 可做错误率和高频事件趋势观察 +- 可配置告警 + +## 第五阶段:日志治理与成本控制 + +### 目标 + +控制噪音、成本和维护复杂度。 + +### 任务 + +1. 明确保留策略 +- 实时日志平台保留周期 +- `system_logs` 保留周期 +- `audit_logs` 保留周期 + +2. 限制 DEBUG/INFO 噪音 +3. 脱敏检查 +4. 高价值事件分级 + +### 验收标准 + +- 数据量可控 +- 日志可用性提升而不是噪音堆积 +- 无敏感信息泄露 + +## 开发任务拆分 + +## A. 后端 + +### A1. 日志中间件 + +- 新增 request id middleware +- 注入 logger context +- 响应头透出 request id + +### A2. logger 封装 + +- 提供统一 helper +- 统一 event 与 context 传法 + +### A3. 高价值日志落库 + +- 新增 model / migration +- 新增写入 service +- 对关键异常和 Earth ingest 进行入库 + +### A4. 审计日志 + +- 对 system control、settings、datasource 管理接口补 audit + +## B. 前端 + +### B1. logger SDK + +- `logger.error/warn/info/debug` +- 自动补 page、module、url + +### B2. Earth 接入 + +- 图层加载 +- 新闻模块 +- 巡航模块 +- 关键交互失败 + +### B3. 管理台接入 + +- 系统控制页面 +- 设置页 +- 数据源管理页 + +### B4. 日志页面 + +- 分离实时视图与历史视图 +- 补 request id / event / source / level 查询 + +## 里程碑建议 + +### M1. 规范收口 + +- 输出统一日志规范 +- 清理核心裸输出 + +### M2. 请求链串联 + +- request id 打通 + +### M3. 关键事件落库 + +- `system_logs` + `audit_logs` + +### M4. 平台化 + +- Loki/ELK 接入 + +## 风险与取舍 + +### 风险 1:直接全量入库 + +不建议。 + +问题: + +- 数据膨胀快 +- 检索体验差 +- 业务库压力增加 + +### 风险 2:只做实时日志不做高价值持久化 + +不够。 + +问题: + +- 故障后无法追溯 +- 前端错误容易因 TTL / 重启丢失 + +### 风险 3:没有事件命名治理 + +问题: + +- 页面能看日志,但无法做稳定聚合与检索 + +## 推荐实施顺序 + +最推荐的实际推进顺序: + +1. 统一后端/前端 logger 规范 +2. 打通 request id +3. 新增 `system_logs` 与 `audit_logs` +4. 只持久化高价值事件 +5. 最后接日志平台 + +这是对当前 Planet 成本最低、收益最高、也最接近企业级实践的路线。 + +## 本计划的最终验收 + +当以下条件满足时,可认为日志系统初步达到企业级可用水平: + +- 后端关键失败路径都有结构化日志 +- Earth 前端关键失败可统一上报 +- 管理员关键操作可审计 +- 高价值错误可长期追溯 +- 实时日志与历史事件分层明确 +- 至少有 request_id 或等价链路串联能力 +- 日志页不再只是“看文件”,而是具备查询真实事件的能力 diff --git a/docs/version-history.md b/docs/version-history.md index 6df232c8..05dc5b4c 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -16,12 +16,13 @@ ## Current Version - `main` 当前主线历史推导到:`0.16.5` -- `dev` 当前开发分支历史推导到:`0.37.2` +- `dev` 当前开发分支历史推导到:`0.38.0` ## Timeline | Version | Type | Branch | Commit | Summary | | --- | --- | --- | --- | --- | +| `0.38.0` | feature | `dev` | `pending` | Earth 新闻接入通用巡航与专用卡片链路,系统日志页升级为结构化时间/级别过滤与真正字符串检索 | | `0.37.2` | bugfix | `dev` | `pending` | Earth 图层系统新增经纬线开关,并将经纬线接入统一 layer registry、移动端抽屉与设置持久化流 | | `0.37.1` | bugfix | `dev` | `pending` | 修复 `planet.sh` 在 `uvicorn --reload` 场景下未清理旧 worker 的问题,避免后端重启后仍停留旧实例并导致算力中心聚合接口 404 | | `0.37.0` | feature | `dev` | `pending` | Earth 连线系统从巡航语义中完全解耦为通用 callout connector,统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 | diff --git a/frontend/package.json b/frontend/package.json index 179ce69b..febe4770 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.37.2", + "version": "0.38.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index ec3a514a..6190665d 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -421,8 +421,10 @@ .earth-mobile-drawer-sheet { position: relative; + display: flex; + flex-direction: column; min-height: calc(240px + var(--safe-bottom)); - max-height: min(78vh, calc(100vh - 72px - var(--safe-top))); + height: min(78vh, calc(100vh - 72px - var(--safe-top))); padding: 10px 14px calc(14px + var(--safe-bottom)) 14px; border-top-left-radius: 28px; border-top-right-radius: 28px; @@ -438,6 +440,8 @@ } .earth-mobile-drawer-header { + order: 1; + flex: 0 0 auto; padding: 10px 4px 8px; cursor: ns-resize; user-select: none; @@ -452,45 +456,173 @@ background: rgba(225, 239, 255, 0.18); } -.earth-mobile-drawer-tabs { +.earth-mobile-drawer-nav { + order: 3; + flex: 0 0 auto; display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; - padding-bottom: 12px; + margin-top: 8px; + padding-top: 10px; + border-top: 1px solid rgba(214, 231, 247, 0.08); +} + +.earth-mobile-drawer-nav-copy { + display: none; +} + +.earth-mobile-drawer-nav-kicker { + color: rgba(167, 194, 223, 0.58); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.earth-mobile-drawer-nav-title { + color: var(--hud-title); + font-size: 0.92rem; + font-weight: 700; + letter-spacing: 0.01em; +} + +.earth-mobile-drawer-tabs-shell { + position: relative; + padding: 8px 8px 2px; + border: 1px solid rgba(207, 226, 245, 0.06); + border-radius: 22px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.01)), + rgba(6, 16, 31, 0.32); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 10px 22px rgba(2, 8, 18, 0.12); + overflow: hidden; +} + +.earth-mobile-drawer-tabs { + display: flex; + align-items: stretch; + gap: 10px; + padding: 2px; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x proximity; +} + +.earth-mobile-drawer-tabs-fade { + position: absolute; + top: 8px; + bottom: 8px; + width: 22px; + z-index: 2; + pointer-events: none; +} + +.earth-mobile-drawer-tabs-fade--left { + left: 0; + background: linear-gradient(90deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0)); +} + +.earth-mobile-drawer-tabs-fade--right { + right: 0; + background: linear-gradient(270deg, rgba(6, 16, 31, 0.92), rgba(6, 16, 31, 0)); } .earth-mobile-drawer-tab { - min-height: 40px; - border: 1px solid rgba(201, 225, 247, 0.12); - border-radius: 12px; - background: rgba(255, 255, 255, 0.04); + flex: 0 0 auto; + min-width: 68px; + min-height: 58px; + padding: 8px 11px 9px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + border: 1px solid rgba(201, 225, 247, 0.06); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015)), + rgba(7, 18, 34, 0.38); color: var(--hud-text-muted); - font-size: 0.78rem; - font-weight: 600; - letter-spacing: 0.03em; cursor: pointer; - transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease; + scroll-snap-align: start; + position: relative; + z-index: 1; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; } .earth-mobile-drawer-tab.is-active { color: var(--hud-title); - background: rgba(122, 180, 255, 0.14); - border-color: rgba(122, 180, 255, 0.24); + background: + linear-gradient(180deg, rgba(111, 174, 255, 0.18), rgba(74, 126, 210, 0.08)), + rgba(10, 26, 52, 0.58); + border-color: rgba(122, 180, 255, 0.22); + box-shadow: + 0 8px 18px rgba(11, 22, 40, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +.earth-mobile-drawer-tab:hover { + color: var(--hud-text); + border-color: rgba(201, 225, 247, 0.14); +} + +.earth-mobile-drawer-tabs::-webkit-scrollbar { + display: none; +} + +.earth-mobile-drawer-tab-icon { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + font-size: 17px; + color: rgba(221, 235, 248, 0.88); + background: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.earth-mobile-drawer-tab.is-active .earth-mobile-drawer-tab-icon { + color: #f6fbff; + background: + linear-gradient(180deg, rgba(161, 207, 255, 0.26), rgba(95, 154, 237, 0.18)), + rgba(255, 255, 255, 0.08); +} + +.earth-mobile-drawer-tab-label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; } .earth-mobile-drawer-content { + order: 2; + flex: 1 1 auto; position: relative; min-height: 0; - max-height: calc(78vh - 108px - var(--safe-bottom)); + max-height: none; overflow: hidden; } .earth-mobile-drawer-slot { display: none; min-height: 0; - max-height: inherit; + height: 100%; + max-height: none; overflow: auto; overscroll-behavior: contain; + padding-bottom: 6px; } .earth-mobile-drawer-slot.is-active { @@ -615,6 +747,14 @@ min-height: 0; } +.earth-mobile-page--tv { + gap: 8px; +} + +.earth-mobile-page--tv .earth-mobile-page-intro { + gap: 2px; +} + .earth-mobile-page-intro { display: flex; flex-direction: column; @@ -634,6 +774,11 @@ line-height: 1.4; } +.earth-mobile-page--tv .earth-mobile-page-summary { + font-size: 0.72rem; + line-height: 1.3; +} + .earth-mobile-layer-list, .earth-mobile-news-board-list, .earth-mobile-search-results { @@ -765,6 +910,10 @@ gap: 10px; } +.earth-mobile-page--situation .earth-mobile-stats-grid > * { + min-width: 0; +} + .earth-mobile-stat-card, .earth-mobile-situation-card, .earth-mobile-news-focus, @@ -785,12 +934,23 @@ gap: 6px; } +.earth-mobile-page--situation .earth-mobile-stat-card { + min-width: 0; + padding: 12px 12px; + gap: 4px; +} + .earth-mobile-stat-num { color: var(--hud-title); font-size: 1.26rem; font-weight: 700; } +.earth-mobile-page--situation .earth-mobile-stat-num { + font-size: 1.08rem; + line-height: 1.1; +} + .earth-mobile-stat-label, .earth-mobile-situation-card-subtitle, .earth-mobile-news-focus-kicker, @@ -802,6 +962,52 @@ text-transform: uppercase; } +.earth-mobile-page--situation .earth-mobile-stat-label { + font-size: 0.64rem; + letter-spacing: 0.04em; + line-height: 1.25; + white-space: normal; + word-break: break-word; +} + +@media (orientation: landscape) and (max-height: 540px) { + .earth-mobile-page--situation { + gap: 8px; + } + + .earth-mobile-page--situation .earth-mobile-page-intro { + gap: 1px; + } + + .earth-mobile-page--situation .earth-mobile-page-kicker { + font-size: 0.6rem; + } + + .earth-mobile-page--situation .earth-mobile-page-summary { + font-size: 0.68rem; + line-height: 1.2; + } + + .earth-mobile-page--situation .earth-mobile-stats-grid { + gap: 8px; + } + + .earth-mobile-page--situation .earth-mobile-stat-card { + padding: 10px 10px; + gap: 3px; + border-radius: 15px; + } + + .earth-mobile-page--situation .earth-mobile-stat-num { + font-size: 0.98rem; + } + + .earth-mobile-page--situation .earth-mobile-stat-label { + font-size: 0.6rem; + line-height: 1.15; + } +} + .earth-mobile-situation-card { display: flex; flex-direction: column; @@ -857,17 +1063,124 @@ width: 100%; } +.earth-mobile-tv-overview { + border-radius: 14px; + border: 1px solid rgba(212, 227, 244, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), transparent), + rgba(255, 255, 255, 0.03); + padding: 8px 10px; +} + +.earth-mobile-tv-overview-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; +} + +.earth-mobile-tv-overview-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.earth-mobile-tv-overview-kicker { + color: var(--hud-text-muted); + font-size: 0.58rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.earth-mobile-tv-overview-headline { + color: var(--hud-title); + font-size: 0.84rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.earth-mobile-tv-overview-summary { + color: var(--hud-text-soft); + font-size: 0.66rem; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.earth-mobile-tv-overview-tags { + display: flex; + flex-wrap: nowrap; + gap: 4px; + margin-top: 1px; + overflow: hidden; +} + +.earth-mobile-tv-overview-tag { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 6px; + border-radius: 999px; + border: 1px solid rgba(212, 227, 244, 0.12); + background: rgba(255, 255, 255, 0.06); + color: var(--hud-text); + font-size: 0.62rem; + line-height: 1; + white-space: nowrap; +} + +.earth-mobile-tv-overview-tag--status { + color: var(--hud-accent-strong); + border-color: rgba(122, 180, 255, 0.2); + background: rgba(122, 180, 255, 0.12); +} + +.earth-mobile-tv-overview-actions { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; +} + +.earth-mobile-tv-overview-bar:focus-visible { + outline: 2px solid rgba(122, 180, 255, 0.5); + outline-offset: 4px; + border-radius: 12px; +} + +.earth-mobile-tv-meta-wrap { + overflow: hidden; + max-height: 240px; + opacity: 1; + margin-top: 10px; + transition: max-height 0.22s ease, opacity 0.18s ease, margin 0.22s ease; +} + +.earth-mobile-tv-meta-wrap.is-collapsed { + max-height: 0; + opacity: 0; + pointer-events: none; + margin-top: 0; +} + .earth-mobile-tv-select { border: 1px solid rgba(201, 225, 247, 0.14); - border-radius: 14px; + border-radius: 12px; background: rgba(255, 255, 255, 0.04); color: var(--hud-text); - padding: 12px 14px; + padding: 10px 12px; } .earth-mobile-tv-player { position: relative; - min-height: 220px; + aspect-ratio: 16 / 9; + min-height: 0; border-radius: 18px; overflow: hidden; border: 1px solid rgba(201, 225, 247, 0.1); @@ -912,6 +1225,28 @@ font-weight: 600; } +.earth-mobile-action-btn:disabled { + opacity: 0.42; + cursor: default; +} + +.earth-mobile-action-btn--compact { + min-width: 32px; + min-height: 32px; + padding: 0; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + border-color: rgba(212, 227, 244, 0.14); + color: var(--hud-text); + flex: 0 0 auto; +} + +.earth-mobile-action-btn--compact .material-symbols-rounded { + font-size: 0.92rem; +} + + + .earth-mobile-action-btn--ghost { background: rgba(255, 255, 255, 0.04); border-color: rgba(212, 227, 244, 0.1); @@ -961,6 +1296,43 @@ background: rgba(122, 180, 255, 0.14); } +.earth-mobile-settings-chip-group { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; +} + +.earth-mobile-settings-chip { + border: 1px solid rgba(212, 227, 244, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: var(--hud-text-soft); + padding: 9px 14px; + font: inherit; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + transform 0.18s ease; +} + +.earth-mobile-settings-chip:hover { + color: var(--hud-text); + transform: translateY(-1px); +} + +.earth-mobile-settings-chip.is-active { + color: var(--hud-title); + border-color: rgba(122, 180, 255, 0.24); + background: + radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.18), transparent 58%), + linear-gradient(180deg, rgba(122, 180, 255, 0.16), rgba(82, 123, 186, 0.22)); +} + .earth-mobile-settings-switch { position: relative; display: inline-flex; @@ -1042,6 +1414,81 @@ gap: 10px; } +.earth-mobile-news-detail { + display: grid; + gap: 12px; +} + +.earth-mobile-news-detail-kicker { + color: rgba(255, 215, 122, 0.82); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.earth-mobile-news-detail-title { + color: var(--hud-title); + font-size: 1rem; + line-height: 1.5; + font-weight: 700; +} + +.earth-mobile-news-detail-summary-shell { + position: relative; + padding: 12px 13px; + border: 1px solid rgba(255, 215, 122, 0.12); + background: + linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)), + radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%), + rgba(7, 15, 29, 0.42); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 10px 28px rgba(0, 0, 0, 0.16); + overflow: hidden; +} + +.earth-mobile-news-detail-summary-shell::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%); + opacity: 0.42; + transform: translateX(-100%); + animation: infoCardNewsScan 3.2s linear infinite; + pointer-events: none; +} + +.earth-mobile-news-detail-summary-label { + color: rgba(188, 212, 238, 0.72); + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-bottom: 7px; +} + +.earth-mobile-news-detail-summary { + color: #d7e6f7; + font-size: 0.9rem; + line-height: 1.72; + min-height: 5.2em; + white-space: pre-wrap; + word-break: break-word; +} + +.earth-mobile-news-detail-summary.is-typing::after { + content: ""; + display: inline-block; + width: 0.58em; + height: 1.05em; + margin-left: 0.16em; + vertical-align: -0.14em; + background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78)); + box-shadow: 0 0 10px rgba(255, 215, 122, 0.28); + animation: infoCardNewsCaret 0.9s steps(1, end) infinite; +} + .earth-mobile-detail-row { display: flex; flex-direction: column; @@ -1065,14 +1512,28 @@ .layout-mode-mobile .earth-status-message, .layout-mode-mobile .earth-error-message { - top: calc(var(--safe-top) + 130px); + position: fixed; + top: calc(var(--safe-top) + 10px); left: auto; - right: calc(8px + var(--safe-right)); - transform: translate(0, -10px); + right: calc(10px + var(--safe-right)); + transform: translate(12px, 0); min-width: 0; - max-width: min(200px, 52vw); - font-size: 0.78rem; - padding: 6px 14px 6px 10px; + max-width: min(220px, 46vw); + border-radius: 16px; + font-size: 0.74rem; + line-height: 1.28; + padding: 7px 12px 7px 10px; + gap: 8px; + border-color: rgba(214, 230, 247, 0.1); + border-left-color: transparent; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.06), transparent 52%), + linear-gradient(180deg, rgba(17, 29, 46, 0.92), rgba(7, 14, 24, 0.9)); + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.22), + 0 0 0 1px rgba(255, 255, 255, 0.03); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); } .layout-mode-mobile .earth-status-message.visible, @@ -1080,6 +1541,34 @@ transform: translate(0, 0); } +.layout-mode-mobile .earth-error-message { + top: calc(var(--safe-top) + 60px); +} + +.layout-mode-mobile .earth-status-indicator { + gap: 4px; +} + +.layout-mode-mobile .earth-status-dot { + width: 6px; + height: 6px; + box-shadow: + 0 0 6px rgba(145, 186, 255, 0.48), + 0 0 14px rgba(145, 186, 255, 0.16); +} + +.layout-mode-mobile .earth-status-text { + font-weight: 600; +} + +.layout-mode-mobile .earth-status-message.loading { + max-width: min(240px, 52vw); +} + +.layout-mode-mobile .earth-status-message.loading .earth-status-text { + color: rgba(232, 242, 252, 0.92); +} + .hud-panel-row { display: flex; justify-content: space-between; @@ -1754,6 +2243,50 @@ 0 8px 18px rgba(0, 0, 0, 0.2); } +.earth-settings-chip-group { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + align-self: flex-start; +} + +.earth-settings-chip { + border: 1px solid rgba(212, 227, 244, 0.1); + border-radius: 999px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), + rgba(255, 255, 255, 0.025); + color: var(--hud-text-soft); + padding: calc(6px * var(--hud-scale)) calc(12px * var(--hud-scale)); + font: inherit; + font-size: calc(0.7rem * var(--hud-scale)); + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: + background 0.18s ease, + border-color 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; +} + +.earth-settings-chip:hover { + color: var(--hud-text); + transform: translateY(-1px); +} + +.earth-settings-chip.is-active { + color: var(--hud-title); + border-color: rgba(122, 180, 255, 0.24); + background: + radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.22), transparent 58%), + linear-gradient(180deg, rgba(121, 159, 207, 0.2), rgba(72, 101, 139, 0.26)); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 18px rgba(0, 0, 0, 0.16); +} + .earth-settings-slider { flex: 1 1 auto; width: 100%; diff --git a/frontend/public/earth/css/info-panel.css b/frontend/public/earth/css/info-panel.css index 120611fa..e376fc97 100644 --- a/frontend/public/earth/css/info-panel.css +++ b/frontend/public/earth/css/info-panel.css @@ -381,6 +381,113 @@ } .info-card.bgp .info-card-header h3 { color: var(--hud-accent-strong); } +.info-card.news .info-card-header { + background: rgba(255, 196, 92, 0.12); + border-bottom-color: rgba(255, 196, 92, 0.16); +} +.info-card.news .info-card-header h3 { color: #ffd77a; } + +.info-card.news .info-card-content { + padding-top: calc(10px * var(--hud-scale)); + padding-bottom: calc(12px * var(--hud-scale)); +} + +.info-card-news-layout { + display: grid; + gap: calc(10px * var(--hud-scale)); +} + +.info-card-news-kicker { + color: rgba(255, 215, 122, 0.82); + font-size: calc(0.62rem * var(--hud-scale)); + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.info-card-news-title { + color: var(--hud-title); + font-size: calc(0.96rem * var(--hud-scale)); + line-height: 1.45; + font-weight: 700; + text-wrap: balance; +} + +.info-card-news-summary-shell { + position: relative; + padding: calc(10px * var(--hud-scale)) calc(12px * var(--hud-scale)); + border: 1px solid rgba(255, 215, 122, 0.12); + background: + linear-gradient(180deg, rgba(255, 215, 122, 0.05), rgba(255, 255, 255, 0.02)), + radial-gradient(circle at top left, rgba(120, 180, 255, 0.08), transparent 56%), + rgba(7, 15, 29, 0.42); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 10px 28px rgba(0, 0, 0, 0.16); + overflow: hidden; +} + +.info-card-news-summary-shell::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(90deg, transparent 0%, rgba(122, 180, 255, 0.12) 50%, transparent 100%); + opacity: 0.42; + transform: translateX(-100%); + animation: infoCardNewsScan 3.2s linear infinite; + pointer-events: none; +} + +.info-card-news-summary-label { + color: rgba(188, 212, 238, 0.72); + font-size: calc(0.6rem * var(--hud-scale)); + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + margin-bottom: calc(6px * var(--hud-scale)); +} + +.info-card-news-summary { + position: relative; + color: #d7e6f7; + font-size: calc(0.8rem * var(--hud-scale)); + line-height: 1.65; + min-height: calc(4.8em * var(--hud-scale)); + white-space: pre-wrap; + word-break: break-word; +} + +.info-card-news-summary.is-typing::after { + content: ""; + display: inline-block; + width: 0.58em; + height: 1.05em; + margin-left: 0.16em; + vertical-align: -0.14em; + background: linear-gradient(180deg, rgba(255, 215, 122, 0.96), rgba(122, 180, 255, 0.78)); + box-shadow: 0 0 10px rgba(255, 215, 122, 0.28); + animation: infoCardNewsCaret 0.9s steps(1, end) infinite; +} + +@keyframes infoCardNewsScan { + from { + transform: translateX(-100%); + } + to { + transform: translateX(100%); + } +} + +@keyframes infoCardNewsCaret { + 0%, 49% { + opacity: 1; + } + 50%, 100% { + opacity: 0; + } +} + /* ── Layout-expanded: slide left column off-screen ────────────── */ .earth-app.layout-expanded .earth-left-column { diff --git a/frontend/public/earth/css/news-panel.css b/frontend/public/earth/css/news-panel.css index 23941ce3..10189f5f 100644 --- a/frontend/public/earth/css/news-panel.css +++ b/frontend/public/earth/css/news-panel.css @@ -135,6 +135,15 @@ box-shadow: 0 0 0 1px rgba(122, 214, 255, 0.08) inset; } +.news-story-card--cruise { + border-color: rgba(255, 213, 128, 0.42); + background: + linear-gradient(180deg, rgba(255, 248, 220, 0.1), rgba(255, 184, 77, 0.08)); + box-shadow: + 0 0 0 1px rgba(255, 213, 128, 0.18) inset, + 0 0 18px rgba(255, 184, 77, 0.12); +} + .news-story-meta, .news-story-tags { display: flex; diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index b39924a1..513d9756 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -475,13 +475,41 @@
-
- - - - - - +
+
+ Earth Menu + 模块切换 +
+
+ + +
+ + + + + + +
+
@@ -589,13 +617,6 @@ 移动端新闻直播和频道切换
-
- 等待加载直播源 -
暂无可用频道
-
当前未配置可播放新闻直播源
-
频道目录待同步
-
支持后台配置默认源与采集器补充源。
-
暂无可播放直播源,请先在系统配置中添加频道。
-
- - +
+ +
+
+ 等待加载直播源 +
暂无可用频道
+
当前未配置可播放新闻直播源
+
频道目录待同步
+
支持后台配置默认源与采集器补充源。
+
+
@@ -625,13 +669,23 @@
旋转模式 - 巡航模式会按 BGP 事件轮播聚焦 + 巡航模式会按已启用模块的目标队列轮播聚焦
+
+
+ 巡航模块 + 选择哪些业务模块参与巡航队列。默认 BGP,新闻可按需加入。 +
+
+ + +
+
视图
@@ -776,7 +830,7 @@
旋转模式 - 旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦 + 旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦
+
+
+ 巡航模块 + 选择哪些业务模块参与巡航队列。默认 BGP,新闻会按发生地与时间加入巡航目标并显示新闻卡片。 +
+
+ + +
+
diff --git a/frontend/public/earth/js/client-logs.js b/frontend/public/earth/js/client-logs.js new file mode 100644 index 00000000..d6f5a122 --- /dev/null +++ b/frontend/public/earth/js/client-logs.js @@ -0,0 +1,96 @@ +import { PATHS } from "./constants.js"; + +const RECENT_EVENT_TTL_MS = 15_000; +const recentEventMap = new Map(); + +function normalizeErrorDetail(detail) { + if (!detail) return ""; + if (detail instanceof Error) { + return detail.stack || detail.message || String(detail); + } + if (typeof detail === "string") { + return detail; + } + try { + return JSON.stringify(detail); + } catch { + return String(detail); + } +} + +function dedupeKey(level, message, detail, category) { + return `${level}::${category || ""}::${message}::${detail}`; +} + +function shouldSkip(level, message, detail, category) { + const key = dedupeKey(level, message, detail, category); + const now = Date.now(); + const lastSeenAt = recentEventMap.get(key); + recentEventMap.set(key, now); + + for (const [entryKey, entryTime] of recentEventMap.entries()) { + if (now - entryTime > RECENT_EVENT_TTL_MS) { + recentEventMap.delete(entryKey); + } + } + + return lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS; +} + +export async function reportEarthClientLog({ + level = "error", + message, + category = "runtime", + module = "earth", + detail = "", +}) { + if (!message) return; + const normalizedDetail = normalizeErrorDetail(detail); + if (shouldSkip(level, message, normalizedDetail, category)) { + return; + } + + try { + await fetch(PATHS.earthClientLogsApi, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + level, + message, + category, + module, + url: window.location.href, + detail: normalizedDetail.slice(0, 4000), + }), + keepalive: true, + }); + } catch { + // Swallow reporting failures to avoid recursive log noise. + } +} + +export function registerEarthClientErrorHandlers() { + window.addEventListener("error", (event) => { + console.error("全局错误:", event.error); + void reportEarthClientLog({ + level: "error", + category: "window-error", + module: "main", + message: event.message || "Earth 页面发生未捕获错误", + detail: event.error || `${event.filename || ""}:${event.lineno || 0}:${event.colno || 0}`, + }); + }); + + window.addEventListener("unhandledrejection", (event) => { + console.error("未处理的 Promise 错误:", event.reason); + void reportEarthClientLog({ + level: "error", + category: "unhandledrejection", + module: "main", + message: "Earth 页面发生未处理 Promise 错误", + detail: event.reason, + }); + }); +} diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index a68696a8..9edb4c07 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -18,6 +18,13 @@ export const ROTATION_MODE = { CRUISE: "cruise", }; +export const CRUISE_MODULES = { + BGP: "bgp", + NEWS: "news", +}; + +export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP]; + export const CRUISE_CONFIG = { dwellMs: 7_000, focusDurationMs: 1_400, @@ -160,6 +167,7 @@ export const PATHS = { bgpApi: '/api/v1/visualization/geo/bgp-anomalies', bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents', bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors', + earthClientLogsApi: '/api/v1/system/logs/earth-client', }; export const COMPUTE_CENTER_CONFIG = { diff --git a/frontend/public/earth/js/controls.js b/frontend/public/earth/js/controls.js index 2ae9a6e2..32bfc6ad 100644 --- a/frontend/public/earth/js/controls.js +++ b/frontend/public/earth/js/controls.js @@ -1,7 +1,13 @@ // controls.js - Zoom, rotate and toggle controls import * as THREE from "three"; -import { CONFIG, EARTH_CONFIG, ROTATION_MODE } from "./constants.js"; +import { + CONFIG, + CRUISE_MODULES, + DEFAULT_CRUISE_MODULES, + EARTH_CONFIG, + ROTATION_MODE, +} from "./constants.js"; import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js"; import { toggleTerrain, @@ -109,6 +115,7 @@ let activeMobileDrawerId = null; let mobileDrawerOpen = false; let mobileDrawerCard = "layers"; let mobileDrawerHintTimer = null; +const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES)); function detectLayoutMode() { const width = window.innerWidth; @@ -633,6 +640,7 @@ function getCurrentPanelVisibilitySnapshot() { function getCurrentSharedSettingsSnapshot() { return { rotationMode, + cruiseModules: getCruiseModules(), layerVisibility: Object.fromEntries( getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]), ), @@ -667,6 +675,7 @@ function cloneEarthSettings(settings) { version: 2, shared: { rotationMode: settings.shared.rotationMode, + cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)], terrainOpacity: settings.shared.terrainOpacity, dayNightEnabled: settings.shared.dayNightEnabled, defaultEarthZoom: settings.shared.defaultEarthZoom, @@ -738,6 +747,14 @@ function normalizeEarthSettings(rawSettings, defaults) { sharedSettings?.rotationMode === ROTATION_MODE.CRUISE ? ROTATION_MODE.CRUISE : defaults.shared.rotationMode; + const requestedCruiseModules = Array.isArray(sharedSettings?.cruiseModules) + ? sharedSettings.cruiseModules + : defaults.shared.cruiseModules; + const nextCruiseModules = Array.from( + new Set( + requestedCruiseModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId)), + ), + ); const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity); const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean" ? sharedSettings.dayNightEnabled @@ -750,6 +767,9 @@ function normalizeEarthSettings(rawSettings, defaults) { version: 2, shared: { rotationMode: nextRotationMode, + cruiseModules: nextCruiseModules.length > 0 + ? nextCruiseModules + : [...DEFAULT_CRUISE_MODULES], layerVisibility: normalizedLayerVisibility, terrainOpacity: Number.isFinite(nextTerrainOpacity) ? nextTerrainOpacity @@ -832,6 +852,79 @@ function persistEarthSettings() { } } +function dispatchCruiseModulesChange() { + window.dispatchEvent( + new CustomEvent("earth:cruise-modules-change", { + detail: { + modules: getCruiseModules(), + }, + }), + ); +} + +function normalizeCruiseModules(nextModules) { + const sourceModules = Array.isArray(nextModules) ? nextModules : DEFAULT_CRUISE_MODULES; + const normalizedModules = Array.from( + new Set(sourceModules.filter((moduleId) => ALLOWED_CRUISE_MODULES.has(moduleId))), + ); + return normalizedModules.length > 0 + ? normalizedModules + : [...DEFAULT_CRUISE_MODULES]; +} + +function syncCruiseModuleControls() { + const enabledModules = new Set(getCruiseModules()); + document.querySelectorAll("[data-cruise-module-toggle]").forEach((button) => { + if (!(button instanceof HTMLButtonElement)) return; + const moduleId = button.dataset.cruiseModuleToggle || ""; + const active = enabledModules.has(moduleId); + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); +} + +export function getCruiseModules() { + const configuredModules = earthSettingsState?.shared?.cruiseModules; + return normalizeCruiseModules(configuredModules); +} + +export function isCruiseModuleEnabled(moduleId) { + return getCruiseModules().includes(moduleId); +} + +export function setCruiseModules(nextModules, { persist = true, suppressStatus = false } = {}) { + const normalizedModules = normalizeCruiseModules(nextModules); + const previousModules = getCruiseModules(); + const changed = + normalizedModules.length !== previousModules.length || + normalizedModules.some((moduleId, index) => previousModules[index] !== moduleId); + + if (!changed) { + syncCruiseModuleControls(); + return normalizedModules; + } + + earthSettingsState = cloneEarthSettings( + earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()), + ); + earthSettingsState.shared.cruiseModules = [...normalizedModules]; + syncCruiseModuleControls(); + dispatchCruiseModulesChange(); + + if (persist) { + persistEarthSettings(); + } + + if (!suppressStatus) { + const labels = normalizedModules.map((moduleId) => + moduleId === CRUISE_MODULES.NEWS ? "新闻" : "BGP", + ); + showStatusMessage(`巡航模块已切换为:${labels.join(" + ")}`, "info"); + } + + return normalizedModules; +} + function syncDefaultEarthZoomUi(nextZoom) { const sliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]"); const values = document.querySelectorAll("#default-earth-size-value, [data-default-earth-size-value]"); @@ -894,6 +987,7 @@ async function applyEarthSettings(settings) { }); setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true }); + setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true }); if (typeof settings.shared.dayNightEnabled === "boolean") { applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false }); @@ -1702,6 +1796,7 @@ function setupSettingsControls() { const terrainOpacityValues = document.querySelectorAll("#terrain-opacity-value, [data-terrain-opacity-value]"); const defaultEarthSizeSliders = document.querySelectorAll("#default-earth-size-slider, [data-default-earth-size-slider]"); const rotationModeButtons = document.querySelectorAll("[data-rotation-mode]"); + const cruiseModuleButtons = document.querySelectorAll("[data-cruise-module-toggle]"); const syncTerrainOpacityUi = (nextOpacity) => { const safeOpacity = Math.round(nextOpacity * 100); terrainOpacitySliders.forEach((slider) => { @@ -1756,6 +1851,24 @@ function setupSettingsControls() { }); }); + cruiseModuleButtons.forEach((button) => { + bindListener(button, "click", (event) => { + const target = event.currentTarget; + if (!(target instanceof HTMLButtonElement)) return; + const moduleId = target.dataset.cruiseModuleToggle; + if (!moduleId) return; + + const currentModules = new Set(getCruiseModules()); + if (currentModules.has(moduleId)) { + currentModules.delete(moduleId); + } else { + currentModules.add(moduleId); + } + + setCruiseModules(Array.from(currentModules)); + }); + }); + document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => { if (!(dayNightToggle instanceof HTMLInputElement)) return; bindListener(dayNightToggle, "change", () => { @@ -1772,6 +1885,7 @@ function setupSettingsControls() { applyEarthSettings(loadEarthSettings()); syncAllHudPanelToggles(); syncRotationModeButtons(); + syncCruiseModuleControls(); syncDayNightToggle(dayNightEnabled); } diff --git a/frontend/public/earth/js/earth.js b/frontend/public/earth/js/earth.js index 2171c492..15b16095 100644 --- a/frontend/public/earth/js/earth.js +++ b/frontend/public/earth/js/earth.js @@ -201,7 +201,6 @@ export function createClouds(scene, earthObj) { const geometry = new THREE.SphereGeometry(CONFIG.earthRadius + 3, 64, 64); const material = new THREE.MeshPhongMaterial({ transparent: true, - linewidth: 2, opacity: 0.15, depthTest: true, depthWrite: false, @@ -239,7 +238,6 @@ export function createTerrain(earthObj) { specular: TERRAIN_CONFIG.specular, shininess: TERRAIN_CONFIG.shininess, vertexColors: true, - vertexAlphas: true, transparent: true, opacity: TERRAIN_CONFIG.opacity, flatShading: false, diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index 7d7e57e8..08ed03f7 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -3,6 +3,186 @@ import { showStatusMessage } from './ui.js'; let currentType = null; let cardMounted = false; +let typewriterTimerId = null; +let typewriterToken = 0; +let pendingMobileDetailState = null; +let mobileDetailsListenerBound = false; +let renderedMobileDetailKey = null; + +function getNewsSummaryText(data) { + return (data?.summary || data?.title || '').trim() || '暂无摘要'; +} + +function getNewsSummaryPreview(data, maxLength = 34) { + const text = getNewsSummaryText(data).replace(/\s+/g, ' ').trim(); + if (text.length <= maxLength) return text; + return `${text.slice(0, Math.max(0, maxLength - 1))}…`; +} + +function stopTypewriterAnimation() { + typewriterToken += 1; + if (typewriterTimerId) { + window.clearTimeout(typewriterTimerId); + typewriterTimerId = null; + } +} + +function startTypewriterAnimation(target, text, options = {}) { + if (!(target instanceof HTMLElement)) return; + stopTypewriterAnimation(); + + const content = typeof text === 'string' ? text : ''; + const token = typewriterToken; + const stepMs = Number.isFinite(options.stepMs) ? options.stepMs : 22; + const startDelayMs = Number.isFinite(options.startDelayMs) ? options.startDelayMs : 90; + + target.textContent = ''; + target.classList.add('is-typing'); + + let index = 0; + const tick = () => { + if (token !== typewriterToken) return; + index += 1; + target.textContent = content.slice(0, index); + if (index < content.length) { + typewriterTimerId = window.setTimeout(tick, stepMs); + return; + } + target.classList.remove('is-typing'); + typewriterTimerId = null; + }; + + typewriterTimerId = window.setTimeout(() => { + if (token !== typewriterToken) return; + if (!content) { + target.classList.remove('is-typing'); + typewriterTimerId = null; + return; + } + tick(); + }, startDelayMs); +} + +function renderNewsCardContent(content, data) { + if (!(content instanceof HTMLElement)) return; + const summary = getNewsSummaryText(data); + content.innerHTML = ` +
+
NEWS SIGNAL
+
${data?.title || '新闻事件'}
+
+
SUMMARY
+
+
+
+ `; + const summaryEl = content.querySelector('[data-news-summary]'); + startTypewriterAnimation(summaryEl, summary); +} + +function renderMobileNewsCardContent(content, data) { + if (!(content instanceof HTMLElement)) return; + const summary = getNewsSummaryText(data); + content.innerHTML = ` +
+
NEWS SIGNAL
+
${data?.title || '新闻事件'}
+
+
SUMMARY
+
+
+
+ `; + const summaryEl = content.querySelector('[data-news-summary]'); + startTypewriterAnimation(summaryEl, summary, { stepMs: 20, startDelayMs: 70 }); +} + +function renderMobileDetailContent(type, config, data) { + const content = document.getElementById('mobile-info-card-content'); + if (!(content instanceof HTMLElement)) return; + + stopTypewriterAnimation(); + if (type === 'news') { + renderMobileNewsCardContent(content, data); + return; + } + + let html = ''; + for (const field of config.fields) { + let value = data[field.key]; + if (value === undefined || value === null || value === '') { + value = '-'; + } else if (typeof value === 'number') { + value = value.toLocaleString(); + } + if (field.unit && value !== '-') value = value + ' ' + field.unit; + html += ` +
+ ${field.label} + ${value} +
+ `; + } + content.innerHTML = html; +} + +function getMobileDetailRenderKey(type, data) { + if (type !== 'news') return null; + return [ + type, + data?.id ?? '', + data?.url ?? '', + data?.published_at ?? '', + data?.title ?? '', + ].join('|'); +} + +function ensureMobileDetailsListener() { + if (mobileDetailsListenerBound) return; + mobileDetailsListenerBound = true; + + window.addEventListener('earth:open-details-tab', () => { + if (!document.body.classList.contains('layout-mode-mobile')) return; + if (!pendingMobileDetailState) return; + const nextKey = getMobileDetailRenderKey( + pendingMobileDetailState.type, + pendingMobileDetailState.data, + ); + if (nextKey && nextKey === renderedMobileDetailKey) return; + renderMobileDetailContent( + pendingMobileDetailState.type, + pendingMobileDetailState.config, + pendingMobileDetailState.data, + ); + renderedMobileDetailKey = nextKey; + }); +} + +function renderDefaultCardContent(content, config, data) { + let html = ''; + for (const field of config.fields) { + let value = data[field.key]; + + if (value === undefined || value === null || value === '') { + value = '-'; + } else if (typeof value === 'number') { + value = value.toLocaleString(); + } + + if (field.unit && value !== '-') { + value = value + ' ' + field.unit; + } + + html += ` +
+ ${field.label} + ${value} +
+ `; + } + + content.innerHTML = html; +} // ── Mobile popup ───────────────────────────────────────────── @@ -12,6 +192,7 @@ function getMobilePopupTitle(type, data) { case 'landing_point': return data.name || '登陆点'; case 'satellite': return data.name || '卫星'; case 'bgp': return data.anomaly_type || 'BGP事件'; + case 'news': return data.title || '新闻事件'; case 'bgp_collector': return data.collector || 'BGP观测站'; case 'supercomputer': return data.name || '超算'; case 'gpu_cluster': return data.name || 'GPU集群'; @@ -25,6 +206,7 @@ function getMobilePopupSubtitle(type, data) { case 'landing_point': return data.country || '登陆点'; case 'satellite': return data.norad_id ? `NORAD ${data.norad_id}` : '卫星'; case 'bgp': return data.severity || 'BGP路由异常'; + case 'news': return getNewsSummaryPreview(data, 30) || '态势新闻'; case 'bgp_collector': return data.location || 'BGP观测站'; case 'supercomputer': return data.country || '超级计算机'; case 'gpu_cluster': return data.country || 'GPU集群'; @@ -280,6 +462,20 @@ const CARD_CONFIG = { { key: 'summary', label: '摘要' } ] }, + news: { + icon: '📰', + title: '新闻事件详情', + className: 'news', + fields: [ + { key: 'source', label: '来源' }, + { key: 'published_at_display', label: '发布时间' }, + { key: 'location_label', label: '发生地' }, + { key: 'region_label', label: '区域' }, + { key: 'feed_name', label: '聚合源' }, + { key: 'summary', label: '摘要' }, + { key: 'url', label: '原文链接' } + ] + }, bgp_collector: { icon: '📍', title: 'BGP观测站详情', @@ -616,6 +812,8 @@ export function showInfoCard(type, data, options = {}) { if (document.body.classList.contains('layout-mode-mobile')) { currentType = type; + pendingMobileDetailState = { type, config, data }; + ensureMobileDetailsListener(); // Fill drawer details slot (accessible when user taps popup → opens details tab) const icon = document.getElementById('mobile-info-card-icon'); @@ -624,27 +822,20 @@ export function showInfoCard(type, data, options = {}) { const content = document.getElementById('mobile-info-card-content'); if (icon) icon.textContent = config.icon; - if (title) title.textContent = config.title; - if (typeLabel) typeLabel.textContent = type.replaceAll('_', ' '); + if (title) { + title.textContent = type === 'news' + ? (data?.title || '新闻事件') + : config.title; + } + if (typeLabel) { + typeLabel.textContent = type === 'news' + ? 'news signal' + : type.replaceAll('_', ' '); + } - if (content) { - let html = ''; - for (const field of config.fields) { - let value = data[field.key]; - if (value === undefined || value === null || value === '') { - value = '-'; - } else if (typeof value === 'number') { - value = value.toLocaleString(); - } - if (field.unit && value !== '-') value = value + ' ' + field.unit; - html += ` -
- ${field.label} - ${value} -
- `; - } - content.innerHTML = html; + if (content && type !== 'news') { + renderMobileDetailContent(type, config, data); + renderedMobileDetailKey = null; } // Show the floating mini popup near the touch point (requires coordinates) @@ -667,37 +858,23 @@ export function showInfoCard(type, data, options = {}) { const title = document.getElementById('info-card-title'); const content = document.getElementById('info-card-content'); + stopTypewriterAnimation(); card.className = 'info-card ' + config.className; icon.textContent = config.icon; - title.textContent = config.title; + title.textContent = type === 'news' + ? (data?.title || '新闻事件') + : config.title; - let html = ''; - for (const field of config.fields) { - let value = data[field.key]; - - if (value === undefined || value === null || value === '') { - value = '-'; - } else if (typeof value === 'number') { - value = value.toLocaleString(); - } - - if (field.unit && value !== '-') { - value = value + ' ' + field.unit; - } - - html += ` -
- ${field.label} - ${value} -
- `; + if (type === 'news') { + renderNewsCardContent(content, data); + } else { + renderDefaultCardContent(content, config, data); } - - content.innerHTML = html; showPanel(options.x, options.y, options); } export function hideInfoCard() { + stopTypewriterAnimation(); if (document.body.classList.contains('layout-mode-mobile')) { hideMobilePopup(); document.body.classList.remove('earth-info-open'); @@ -705,6 +882,8 @@ export function hideInfoCard() { new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } }) ); currentType = null; + pendingMobileDetailState = null; + renderedMobileDetailKey = null; return; } hidePanel(); diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index e51b6012..69857d3b 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -2,6 +2,7 @@ import * as THREE from "three"; import { CONFIG, + CRUISE_MODULES, HUD_CONFIG, CABLE_CONFIG, CABLE_STATE, @@ -147,6 +148,7 @@ import { import { setupControls, getAutoRotate, + getCruiseModules, getRotationMode, getShowTerrain, getStartupLoadLayers, @@ -167,6 +169,7 @@ import { import { CalloutConnector } from "./callout-connector.js"; import { CruiseSequencer } from "./cruise-sequencer.js"; import { createBGPCruiseAdapter } from "./bgp-cruise-adapter.js"; +import { createNewsCruiseAdapter } from "./news-cruise-adapter.js"; import { initInfoCard, showInfoCard, @@ -182,6 +185,10 @@ import { mountBrand } from "./brand.js"; import { initTVPanel } from "./tv.js"; import { initNewsPanel, updateNewsViewFocus } from "./news.js"; import { initSearchPanel } from "./search.js"; +import { + registerEarthClientErrorHandlers, + reportEarthClientLog, +} from "./client-logs.js"; export let scene; export let camera; @@ -224,6 +231,7 @@ let sceneLights = null; let cruisePollTimerId = null; let calloutConnector = null; let cruiseBGPAdapter = null; +let cruiseNewsAdapter = null; let cruiseSequencer = null; let activeDragPointerId = null; let activeTouchPoints = new Map(); @@ -1256,6 +1264,143 @@ function ensureBGPCruiseAdapter() { return cruiseBGPAdapter; } +function ensureNewsCruiseAdapter() { + if (cruiseNewsAdapter) return cruiseNewsAdapter; + + cruiseNewsAdapter = createNewsCruiseAdapter({ + camera, + earth: () => getEarth(), + connector: ensureCalloutConnector(), + focusView: (options) => focusEarthView(camera, options), + }); + + return cruiseNewsAdapter; +} + +function getCruiseModuleDefinitions() { + return [ + { + id: CRUISE_MODULES.BGP, + ensureAdapter: ensureBGPCruiseAdapter, + isQueueSourceActive: () => getShowBGP(), + getSortedQueueItems: () => + ensureBGPCruiseAdapter() + .getSortedMarkers() + .map((marker) => { + const markerId = marker?.userData?.id; + if (!markerId) return null; + const parsed = marker?.userData?.created_at_raw || marker?.userData?.created_at; + const timestamp = parsed ? new Date(parsed).getTime() : 0; + return { + id: `bgp:${markerId}`, + moduleId: CRUISE_MODULES.BGP, + sortTimestamp: Number.isFinite(timestamp) ? timestamp : 0, + payload: marker, + }; + }) + .filter(Boolean), + focusQueueItem: (item, options = {}) => + ensureBGPCruiseAdapter().focusMarker(item?.payload, options), + presentQueueItem: (item, options = {}) => + ensureBGPCruiseAdapter().presentMarker(item?.payload, options), + hidePresentation: (options = {}) => + ensureBGPCruiseAdapter().hidePresentation(options), + clearCurrentHighlight: () => ensureBGPCruiseAdapter().clearCurrentHighlight(), + resetPresentation: () => ensureBGPCruiseAdapter().resetPresentation(), + repositionPresentation: (item) => + ensureBGPCruiseAdapter().repositionConnector(item?.payload), + syncKnownEventIds: () => ensureBGPCruiseAdapter().syncKnownEventIds(), + pollForNewQueueItemIds: async () => { + const nextIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds(); + return nextIds.map((id) => `bgp:${id}`); + }, + }, + { + id: CRUISE_MODULES.NEWS, + ensureAdapter: ensureNewsCruiseAdapter, + isQueueSourceActive: () => true, + ensureItemsLoaded: () => ensureNewsCruiseAdapter().ensureItemsLoaded(), + getSortedQueueItems: () => + ensureNewsCruiseAdapter() + .getSortedItems() + .map((item) => ({ + ...item, + moduleId: CRUISE_MODULES.NEWS, + payload: item, + })), + focusQueueItem: (item, options = {}) => + ensureNewsCruiseAdapter().focusItem(item?.payload, options), + presentQueueItem: (item, options = {}) => + ensureNewsCruiseAdapter().presentItem(item?.payload, options), + hidePresentation: (options = {}) => + ensureNewsCruiseAdapter().hidePresentation(options), + clearCurrentHighlight: () => ensureNewsCruiseAdapter().clearCurrentHighlight(), + resetPresentation: () => ensureNewsCruiseAdapter().resetPresentation(), + repositionPresentation: (item) => + ensureNewsCruiseAdapter().repositionConnector(item?.payload), + syncKnownEventIds: () => ensureNewsCruiseAdapter().syncKnownEventIds(), + externalEventName: "earth:news-payload-updated", + extractQueuedItemIdsFromEvent: (event) => { + const itemIds = Array.isArray(event?.detail?.itemIds) ? event.detail.itemIds : []; + if (itemIds.length === 0) return []; + const adapter = ensureNewsCruiseAdapter(); + const newIds = adapter.diffNewEventIds(itemIds); + adapter.syncKnownEventIds(); + return newIds; + }, + }, + ]; +} + +function getCruiseModuleDefinition(moduleId) { + return getCruiseModuleDefinitions().find((module) => module.id === moduleId) || null; +} + +function getEnabledCruiseModuleDefinitions() { + const enabledModuleIds = new Set(getCruiseModules()); + return getCruiseModuleDefinitions().filter((module) => enabledModuleIds.has(module.id)); +} + +function sortCruiseQueueItems(items) { + return items.sort((left, right) => { + const timestampDiff = (right?.sortTimestamp || 0) - (left?.sortTimestamp || 0); + if (timestampDiff !== 0) return timestampDiff; + return String(left?.id || "").localeCompare(String(right?.id || "")); + }); +} + +function getCruiseQueueItemsSorted() { + const items = []; + getEnabledCruiseModuleDefinitions().forEach((module) => { + if (!module.isQueueSourceActive?.()) return; + items.push(...(module.getSortedQueueItems?.() || [])); + }); + return sortCruiseQueueItems(items); +} + +function getCruiseModuleForItem(item) { + return getCruiseModuleDefinition(item?.moduleId); +} + +function getCurrentCruiseBGPMarker() { + const item = cruiseSequencer?.getCurrentItem() ?? null; + return item?.moduleId === CRUISE_MODULES.BGP ? item.payload || null : null; +} + +async function syncCruiseModuleKnownEventIds() { + const modules = getEnabledCruiseModuleDefinitions(); + await Promise.all( + modules.map(async (module) => { + try { + await module.ensureItemsLoaded?.(); + module.syncKnownEventIds?.(); + } catch (error) { + console.warn(`同步巡航模块失败: ${module.id}`, error); + } + }), + ); +} + function isCruisePresentationPinned() { return cruiseSequencer?.isPresentationPinned() === true; } @@ -1265,22 +1410,23 @@ function setCruisePresentationVisible(visible) { cruiseSequencer.setPresentationVisible(visible); } if (!visible) { - ensureBGPCruiseAdapter().resetPresentation(); + getCruiseModuleDefinitions().forEach((module) => { + module.resetPresentation?.(); + }); } } -function clearCruiseMarkerHighlight() { - ensureBGPCruiseAdapter().clearCurrentHighlight(); -} - -function getCruiseMarkersSorted() { - return ensureBGPCruiseAdapter().getSortedMarkers(); +function clearCruiseHighlights() { + getCruiseModuleDefinitions().forEach((module) => { + module.clearCurrentHighlight?.(); + }); } function repositionCruiseConnector() { if (!isCruisePresentationPinned()) return; - const marker = cruiseSequencer?.getCurrentItem() ?? null; - ensureBGPCruiseAdapter().repositionConnector(marker); + const item = cruiseSequencer?.getCurrentItem() ?? null; + const module = getCruiseModuleForItem(item); + module?.repositionPresentation?.(item); } function isCruiseModeActive() { @@ -1292,12 +1438,12 @@ function ensureCruiseSequencer() { cruiseSequencer = new CruiseSequencer({ isActive: () => isCruiseModeActive() && getAutoRotate(), - getItems: () => getCruiseMarkersSorted(), - getItemId: (marker) => marker?.userData?.id || null, + getItems: () => getCruiseQueueItemsSorted(), + getItemId: (item) => item?.id || null, dwellMs: CRUISE_CONFIG.dwellMs, transitionGapMs: CRUISE_TRANSITION_GAP_MS, clearCurrent: () => { - clearCruiseMarkerHighlight(); + clearCruiseHighlights(); clearLockedObject(); hideInfoCard(); setCruisePresentationVisible(false); @@ -1308,11 +1454,11 @@ function ensureCruiseSequencer() { hideInfoCard(); } }, - focusItem: async (marker, { interrupt }) => - ensureBGPCruiseAdapter().focusMarker(marker, { interrupt }), - presentItem: async (marker, { context }) => { + focusItem: async (item, { interrupt }) => + getCruiseModuleForItem(item)?.focusQueueItem?.(item, { interrupt }), + presentItem: async (item, { context }) => { setCruisePresentationVisible(true); - const presented = await ensureBGPCruiseAdapter().presentMarker(marker, { + const presented = await getCruiseModuleForItem(item)?.presentQueueItem?.(item, { context, }); if (!presented) { @@ -1320,8 +1466,8 @@ function ensureCruiseSequencer() { } return presented; }, - hideItem: async (_marker, { context }) => { - await ensureBGPCruiseAdapter().hidePresentation({ context }); + hideItem: async (item, { context }) => { + await getCruiseModuleForItem(item)?.hidePresentation?.({ context }); setCruisePresentationVisible(false); }, }); @@ -1347,10 +1493,23 @@ async function advanceCruiseEvent({ interrupt = false } = {}) { } async function pollCruiseEventsIfNeeded() { - if (!isCruiseModeActive() || !getAutoRotate() || !getShowBGP()) return; + if (!isCruiseModeActive() || !getAutoRotate()) return; try { - const newIds = await ensureBGPCruiseAdapter().pollForNewMarkerIds(); + const modules = getEnabledCruiseModuleDefinitions().filter( + (module) => module.isQueueSourceActive?.() && typeof module.pollForNewQueueItemIds === "function", + ); + const results = await Promise.all( + modules.map(async (module) => { + try { + return await module.pollForNewQueueItemIds(); + } catch (error) { + console.warn(`巡航模式轮询失败: ${module.id}`, error); + return []; + } + }), + ); + const newIds = results.flat(); if (newIds.length === 0) return; ensureCruiseSequencer().enqueue(newIds); @@ -1358,7 +1517,7 @@ async function pollCruiseEventsIfNeeded() { await advanceCruiseEvent({ interrupt: true }); } } catch (error) { - console.warn("巡航模式轮询 BGP 事件失败:", error); + console.warn("巡航模式轮询失败:", error); } } @@ -1390,16 +1549,63 @@ function handleRotationModeChange(event) { } ensureCruisePolling(); - ensureBGPCruiseAdapter().syncKnownEventIds(); - if (!detailActive) { stopCruiseMode({ preserveCard: true }); return; } - advanceCruiseEvent({ interrupt: true }).catch((error) => { - console.warn("启动巡航模式失败:", error); + syncCruiseModuleKnownEventIds() + .catch((error) => { + console.warn("同步巡航模块失败:", error); + }) + .finally(() => { + advanceCruiseEvent({ interrupt: true }).catch((error) => { + console.warn("启动巡航模式失败:", error); + }); + }); +} + +function handleCruiseModulesChange() { + interruptCruisePresentation({ resetLoop: true }); + clearBGPSelection(); + + const syncPromise = syncCruiseModuleKnownEventIds().catch((error) => { + console.warn("刷新巡航模块失败:", error); }); + + if (!isCruiseModeActive()) { + return; + } + + if (!getAutoRotate()) { + stopCruiseMode({ preserveCard: true }); + return; + } + + syncPromise.finally(() => { + advanceCruiseEvent({ interrupt: true }).catch((error) => { + console.warn("切换巡航模块后推进失败:", error); + }); + }); +} + +function handleCruiseModuleItemsUpdated(moduleId, event) { + const module = getCruiseModuleDefinition(moduleId); + if (!module) return; + + const newIds = module.extractQueuedItemIdsFromEvent?.(event) || []; + if (newIds.length === 0) return; + + if (!isCruiseModeActive() || !getAutoRotate() || !getCruiseModules().includes(moduleId)) { + return; + } + + ensureCruiseSequencer().enqueue(newIds); + if (!ensureCruiseSequencer().isBusy()) { + advanceCruiseEvent({ interrupt: true }).catch((error) => { + console.warn(`推进巡航模块失败: ${moduleId}`, error); + }); + } } function clearSelectionAndInfo() { @@ -1726,13 +1932,7 @@ function getCurrentViewCenterCoords() { return vector3ToLatLon(scratchViewCenterWorld); } -window.addEventListener("error", (event) => { - console.error("全局错误:", event.error); -}); - -window.addEventListener("unhandledrejection", (event) => { - console.error("未处理的 Promise 错误:", event.reason); -}); +registerEarthClientErrorHandlers(); export function init() { if (initialized && !destroyed) return; @@ -1984,6 +2184,13 @@ async function loadData() { getSatelliteHydrationToken: () => satelliteHydrationToken, reportError: (label, reason) => { errors.push({ label, reason }); + void reportEarthClientLog({ + level: "error", + category: "startup-load", + module: "layer-startup", + message: `${label}加载失败: ${reason?.message || String(reason)}`, + detail: reason, + }); }, }); @@ -2087,6 +2294,13 @@ export async function setCablesEnabled( clearCableData(getEarth()); updateCableToggleUi(false); const message = `线缆加载失败: ${error?.message || String(error)}`; + void reportEarthClientLog({ + level: "error", + category: "layer-toggle", + module: "cables", + message, + detail: error, + }); if (!suppressLoadingUi) { showError(message); } @@ -2133,6 +2347,13 @@ export async function setSatellitesEnabled( resetSatelliteState(); updateSatelliteToggleUi(false, 0); const message = `卫星加载失败: ${error?.message || String(error)}`; + void reportEarthClientLog({ + level: "error", + category: "layer-toggle", + module: "satellites", + message, + detail: error, + }); if (!suppressLoadingUi) { showError(message); } @@ -2156,13 +2377,21 @@ function setupEventListeners() { const handleClick = (event) => onClick(event); const handlePageHide = () => destroy(); const handleRotationMode = (event) => handleRotationModeChange(event); + const handleCruiseModules = () => handleCruiseModulesChange(); const handleInfoCardDrag = () => repositionCruiseConnector(); bindListener(window, "resize", handleResize); bindListener(window, "pagehide", handlePageHide); bindListener(window, "beforeunload", handlePageHide); bindListener(window, "earth:rotation-mode-change", handleRotationMode); + bindListener(window, "earth:cruise-modules-change", handleCruiseModules); bindListener(window, "earth:info-card-drag", handleInfoCardDrag); + getCruiseModuleDefinitions().forEach((module) => { + if (!module.externalEventName) return; + bindListener(window, module.externalEventName, (event) => { + handleCruiseModuleItemsUpdated(module.id, event); + }); + }); bindListener(renderer.domElement, "pointerdown", handlePointerDown); bindListener(window, "pointermove", handlePointerMove); bindListener(window, "pointerup", handlePointerUp); @@ -2860,7 +3089,7 @@ function animate() { applyCableVisualState(); const activeCruiseMarker = isCruiseModeActive() && isCruisePresentationPinned() - ? cruiseSequencer?.getCurrentItem() ?? null + ? getCurrentCruiseBGPMarker() : null; updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker); updateComputeCenterVisualState(lockedObjectType, lockedObject, camera); diff --git a/frontend/public/earth/js/news-cruise-adapter.js b/frontend/public/earth/js/news-cruise-adapter.js new file mode 100644 index 00000000..e04fe58d --- /dev/null +++ b/frontend/public/earth/js/news-cruise-adapter.js @@ -0,0 +1,357 @@ +import * as THREE from "three"; + +import { CONFIG, CONNECTOR_CONFIG, CRUISE_CONFIG } from "./constants.js"; +import { showInfoCard, hideInfoCard } from "./info-card.js"; +import { latLonToVector3 } from "./utils.js"; +import { + createConnectorPath, + resolveConnectorAnchor, +} from "./callout-connector.js"; +import { + ensureNewsPanelReady, + getNewsPayload, + selectNewsItem, + clearSelectedNewsItem, +} from "./news.js"; + +const CRUISE_PRESENTATION_HIDE_MS = 220; +const CRUISE_CONNECTOR_READY_TIMEOUT_MS = 1200; +const CRUISE_CONNECTOR_DRAW_MS = 420; +const MOBILE_CARD_MARGIN_PX = 14; +const MOBILE_CARD_TOP_RATIO = 0.16; +const MOBILE_CARD_WIDTH_PX = 220; +const scratchNewsWorldPosition = new THREE.Vector3(); + +const REGION_LABELS = { + americas: "美洲", + europe: "欧洲", + "middle-east-africa": "中东与非洲", + "asia-pacific": "亚太", + global: "全球", +}; + +function getItemTimestamp(item) { + const parsed = item?.published_at ? new Date(item.published_at).getTime() : 0; + return Number.isFinite(parsed) ? parsed : 0; +} + +function formatPublishedAt(rawValue) { + if (!rawValue) return "刚刚同步"; + const parsed = new Date(rawValue); + if (Number.isNaN(parsed.getTime())) return "刚刚同步"; + return parsed.toLocaleString("zh-CN", { + hour12: false, + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function getCardPlacement() { + if (document.body.classList.contains("layout-mode-mobile")) { + const safeBottom = + parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--safe-bottom"), + ) || 0; + const width = Math.min(MOBILE_CARD_WIDTH_PX, window.innerWidth - MOBILE_CARD_MARGIN_PX * 2); + return { + x: Math.max(MOBILE_CARD_MARGIN_PX, window.innerWidth - width - MOBILE_CARD_MARGIN_PX), + y: Math.max( + MOBILE_CARD_MARGIN_PX, + Math.min( + window.innerHeight * MOBILE_CARD_TOP_RATIO, + window.innerHeight - safeBottom - 120, + ), + ), + width, + }; + } + + const hudScale = + Number.parseFloat( + getComputedStyle(document.documentElement).getPropertyValue("--hud-scale"), + ) || 1; + const estimatedCardHeight = Math.min(420 * hudScale, window.innerHeight * 0.7); + const estimatedCardWidth = Math.min(300 * hudScale, window.innerWidth - 32); + + return { + x: window.innerWidth * CRUISE_CONFIG.cardAnchorXRatio - estimatedCardWidth * 0.5, + y: window.innerHeight * CRUISE_CONFIG.cardAnchorYRatio - estimatedCardHeight * 0.5, + width: estimatedCardWidth, + height: estimatedCardHeight, + }; +} + +function mapNewsItemToCruiseEvent(item) { + const latitude = Number(item?.latitude); + const longitude = Number(item?.longitude); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + + return { + id: `news:${item.id}`, + sourceId: item.id, + type: "news", + title: item.title || "新闻事件", + summary: item.summary || "", + source: item.source || "", + feedName: item.feed_name || "", + region: item.region || "global", + regionLabel: REGION_LABELS[item.region] || item.region || "全球", + url: item.url || "", + publishedAt: item.published_at || null, + publishedAtDisplay: formatPublishedAt(item.published_at), + latitude, + longitude, + locationLabel: item.location_label || REGION_LABELS[item.region] || "全球", + sortTimestamp: getItemTimestamp(item), + }; +} + +export function createNewsCruiseAdapter({ camera, earth, connector, focusView }) { + let currentItemId = null; + let knownEventIds = new Set(); + let cardPlacement = null; + + function getVisibleMobilePopup() { + const mobilePopup = document.getElementById("earth-mobile-popup"); + return mobilePopup instanceof HTMLElement && !mobilePopup.hasAttribute("hidden") + ? mobilePopup + : null; + } + + function getVisibleInfoPanel() { + const infoPanel = document.getElementById("info-panel"); + return infoPanel instanceof HTMLElement && !infoPanel.hasAttribute("hidden") + ? infoPanel + : null; + } + + function getItemWorldPosition(item) { + const earthObj = earth?.(); + if (!earthObj || !item) return null; + scratchNewsWorldPosition.copy( + latLonToVector3(item.latitude, item.longitude, CONFIG.earthRadius + 0.3), + ); + earthObj.localToWorld(scratchNewsWorldPosition); + return scratchNewsWorldPosition; + } + + function getItemScreenCoords(item) { + if (!camera || !item) return null; + const worldPosition = getItemWorldPosition(item); + if (!worldPosition) return null; + const projected = worldPosition.clone().project(camera); + if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) { + return null; + } + return { + x: ((projected.x + 1) * 0.5) * window.innerWidth, + y: ((1 - projected.y) * 0.5) * window.innerHeight, + }; + } + + function getCardAnchorTarget() { + const mobilePopup = getVisibleMobilePopup(); + if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) { + return { + element: mobilePopup, + side: mobilePopup.dataset.dockSide || "left", + alignRatio: 0.5, + }; + } + return null; + } + + function getCardObstacleTarget(fallbackPlacement = null) { + const mobilePopup = getVisibleMobilePopup(); + if (document.body.classList.contains("layout-mode-mobile") && mobilePopup) { + return { element: mobilePopup }; + } + + const infoPanel = getVisibleInfoPanel(); + if (infoPanel) { + return { element: infoPanel }; + } + + if (fallbackPlacement) { + return { + x: fallbackPlacement.x, + y: fallbackPlacement.y, + width: fallbackPlacement.width ?? 0, + height: fallbackPlacement.height ?? 0, + }; + } + + return null; + } + + function getConnectorPath(item) { + const itemCoords = getItemScreenCoords(item); + if (!itemCoords) return null; + + const targetPlacement = cardPlacement || getCardPlacement(); + if (document.body.classList.contains("layout-mode-mobile")) { + const anchorTarget = getCardAnchorTarget(); + const anchorCoords = resolveConnectorAnchor(anchorTarget); + if (!anchorCoords) return null; + return createConnectorPath(itemCoords, anchorTarget ?? anchorCoords, { + routingMode: "adaptive", + obstacles: getCardObstacleTarget(targetPlacement), + sourceGapPx: CONNECTOR_CONFIG.markerGapPx, + targetGapPx: CONNECTOR_CONFIG.panelGapPx, + obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx, + }); + } + + const infoPanel = getVisibleInfoPanel(); + const target = + infoPanel || + { + x: targetPlacement.x, + y: targetPlacement.y, + width: targetPlacement.width, + height: targetPlacement.height, + }; + + return createConnectorPath(itemCoords, target, { + routingMode: "adaptive", + obstacles: getCardObstacleTarget(targetPlacement), + sourceGapPx: CONNECTOR_CONFIG.markerGapPx, + targetGapPx: CONNECTOR_CONFIG.panelGapPx, + obstacleClearancePx: CONNECTOR_CONFIG.obstacleClearancePx, + }); + } + + function renderConnector(item, { animate = false } = {}) { + const path = getConnectorPath(item); + if (!path) return false; + return connector?.render(path, { animate }) === true; + } + + function getSortedItems() { + const items = Array.isArray(getNewsPayload()?.items) ? getNewsPayload().items : []; + return items + .map(mapNewsItemToCruiseEvent) + .filter(Boolean) + .sort((a, b) => b.sortTimestamp - a.sortTimestamp); + } + + return { + async ensureItemsLoaded() { + await ensureNewsPanelReady(); + }, + getSortedItems, + clearCurrentHighlight() { + currentItemId = null; + clearSelectedNewsItem(); + }, + async focusItem(item, { interrupt = false } = {}) { + if (!item) return; + currentItemId = item.id; + await ensureNewsPanelReady(); + await focusView({ + lat: item.latitude, + lon: item.longitude, + rotLon: item.longitude - 270, + duration: interrupt + ? Math.round(CRUISE_CONFIG.focusDurationMs * 0.78) + : CRUISE_CONFIG.focusDurationMs, + suppressStatus: true, + }); + cardPlacement = getCardPlacement(); + }, + async presentItem(item, { context }) { + if (!item) return false; + + selectNewsItem(item.sourceId); + const placement = cardPlacement || getCardPlacement(); + cardPlacement = placement; + showInfoCard("news", { + title: item.title, + summary: item.summary || item.title || "", + }, { + x: placement.x, + y: placement.y, + absolute: true, + anchorStable: true, + reveal: false, + }); + + await context.nextFrame(); + if (!context.isCurrent()) { + cardPlacement = null; + connector?.hide(); + hideInfoCard(); + return false; + } + + const startedAt = performance.now(); + let connectorReady = false; + while (context.isCurrent()) { + connectorReady = renderConnector(item, { animate: !connectorReady }); + if (connectorReady) break; + if (performance.now() - startedAt >= CRUISE_CONNECTOR_READY_TIMEOUT_MS) { + break; + } + await context.nextFrame(); + } + + if (!connectorReady || !context.isCurrent()) { + cardPlacement = null; + connector?.hide(); + hideInfoCard(); + return false; + } + + const connectorDelayCompleted = await context.wait(CRUISE_CONNECTOR_DRAW_MS); + if (!connectorDelayCompleted || !context.isCurrent()) { + cardPlacement = null; + connector?.hide(); + hideInfoCard(); + return false; + } + + showInfoCard("news", { + title: item.title, + summary: item.summary || item.title || "", + }, { + x: placement.x, + y: placement.y, + absolute: true, + anchorStable: true, + }); + await context.nextFrame(); + return context.isCurrent(); + }, + async hidePresentation({ context }) { + clearSelectedNewsItem(); + connector?.hide(); + hideInfoCard(); + await context.wait(CRUISE_PRESENTATION_HIDE_MS, { secondary: true }); + cardPlacement = null; + }, + repositionConnector(item) { + if (!cardPlacement || !item || !connector?.isVisible?.() || connector.isAnimating?.()) { + return; + } + renderConnector(item, { animate: false }); + }, + resetPresentation() { + clearSelectedNewsItem(); + cardPlacement = null; + connector?.hide(); + }, + syncKnownEventIds() { + knownEventIds = new Set(getSortedItems().map((item) => item.id)); + return knownEventIds; + }, + diffNewEventIds(itemIds = []) { + return itemIds + .map((itemId) => `news:${itemId}`) + .filter((itemId) => !knownEventIds.has(itemId)); + }, + }; +} diff --git a/frontend/public/earth/js/news.js b/frontend/public/earth/js/news.js index 26a2a17b..c93de360 100644 --- a/frontend/public/earth/js/news.js +++ b/frontend/public/earth/js/news.js @@ -1,5 +1,5 @@ import { showStatusMessage } from "./ui.js"; -import { isTVPanelVisible } from "./tv.js"; +import { getActiveTVTab, isTVPanelVisible } from "./tv.js"; // News aggregation now lives inside the shared media panel: // - outer shell: #media-panel @@ -17,6 +17,7 @@ let payload = null; let lastFocus = null; let lastFetchAt = 0; let lastRegionSwitchAt = 0; +let selectedCruiseStoryId = null; function getElements() { const isMobile = document.body.classList.contains("layout-mode-mobile"); return { @@ -154,7 +155,7 @@ function renderPayload(nextPayload) { ? `
${item.summary}
` : ""; return ` - +