Compare commits

..

6 Commits

Author SHA1 Message Date
linkong
e9464a9833 release: bump version to 0.40.1 2026-04-24 17:28:03 +08:00
linkong
86807f6af6 release: bump version to 0.40.0 2026-04-24 15:41:42 +08:00
rayd1o
8b8f7138c0 release: bump version to 0.39.0 2026-04-24 00:48:33 +08:00
linkong
d5f3784ffb release: bump version to 0.38.0 2026-04-23 17:57:35 +08:00
linkong
195a8bf71c release: bump version to 0.37.2 2026-04-23 11:12:49 +08:00
linkong
987c378f99 release: bump version to 0.37.1 2026-04-23 11:05:30 +08:00
49 changed files with 7442 additions and 264 deletions

View File

@@ -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/新闻稿链接里抽地点线索

View File

@@ -1 +1 @@
0.37.0
0.40.1

View File

@@ -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}

View File

@@ -23,8 +23,11 @@ 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
from app.core.logging import get_logger
router = APIRouter()
logger = get_logger(__name__, service="api")
TERRAIN_TILE_URL_TEMPLATE = (
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
)
@@ -181,6 +184,12 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
mean_motion=metadata.get("mean_motion"),
)
constellation_group = _normalize_satellite_constellation_group(
metadata.get("constellation_group"),
record.name,
)
footprint_policy = _get_satellite_footprint_policy(constellation_group)
features.append(
{
"type": "Feature",
@@ -190,6 +199,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
"id": record.id,
"norad_cat_id": norad_id,
"name": record.name,
"constellation_group": constellation_group,
"footprint_policy": footprint_policy,
"international_designator": metadata.get("international_designator"),
"epoch": metadata.get("epoch"),
"inclination": metadata.get("inclination"),
@@ -210,6 +221,31 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
return {"type": "FeatureCollection", "features": features}
def _normalize_satellite_constellation_group(
raw_group: Any,
name: Optional[str],
) -> Optional[str]:
normalized_group = str(raw_group or "").strip().lower()
if normalized_group:
return normalized_group
normalized_name = str(name or "").strip().upper()
if normalized_name.startswith("STARLINK"):
return "starlink"
if normalized_name.startswith("IRIDIUM"):
return "iridium-next"
return None
def _get_satellite_footprint_policy(constellation_group: Optional[str]) -> str:
if constellation_group == "starlink":
return "starlink_ground_footprint"
if constellation_group == "iridium-next":
return "iridium_coverage_ring"
return "none"
def _current_collected_data_stmt(source: str):
return (
select(CollectedData)
@@ -990,6 +1026,21 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
except HTTPException:
raise
except Exception as e:
logger.exception_event(
"Failed to build cables GeoJSON response",
event="visualization.cables.load_failed",
context={"error": str(e)},
)
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 +1077,21 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
except HTTPException:
raise
except Exception as e:
logger.exception_event(
"Failed to build landing points GeoJSON response",
event="visualization.landing_points.load_failed",
context={"error": str(e)},
)
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)}")

View File

@@ -2,7 +2,6 @@
import asyncio
import json
import logging
from datetime import UTC, datetime
from typing import Optional
@@ -10,10 +9,11 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from jose import jwt, JWTError
from app.core.config import settings
from app.core.logging import get_logger
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
logger = logging.getLogger(__name__)
logger = get_logger(__name__, service="api")
router = APIRouter()
@@ -22,11 +22,18 @@ async def authenticate_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
if payload.get("type") != "access":
logger.warning(f"WebSocket auth failed: wrong token type")
logger.warning_event(
"WebSocket auth failed: wrong token type",
event="auth.websocket.invalid_token_type",
)
return None
return payload
except JWTError as e:
logger.warning(f"WebSocket auth failed: {e}")
logger.warning_event(
"WebSocket auth failed",
event="auth.websocket.decode_failed",
context={"error": str(e)},
)
return None
@@ -36,10 +43,17 @@ async def websocket_endpoint(
token: str = Query(...),
):
"""WebSocket endpoint for real-time data"""
logger.info(f"WebSocket connection attempt with token: {token[:20]}...")
logger.info_event(
"WebSocket connection attempt",
event="auth.websocket.connection_attempt",
context={"token_preview": f"{token[:8]}..."},
)
payload = await authenticate_token(token)
if payload is None:
logger.warning("WebSocket authentication failed, closing connection")
logger.warning_event(
"WebSocket authentication failed, closing connection",
event="auth.websocket.connection_rejected",
)
await websocket.close(code=4001)
return

View File

@@ -1,15 +1,15 @@
"""Redis caching service"""
import json
import logging
from datetime import timedelta
from typing import Optional, Any
import redis
from app.core.config import settings
from app.core.logging import get_logger
logger = logging.getLogger(__name__)
logger = get_logger(__name__)
# Lazy Redis client initialization
@@ -47,7 +47,7 @@ class CacheService:
return json.loads(value)
return None
except Exception as e:
logger.warning(f"Cache get error: {e}")
logger.warning_event("Cache get error", event="cache.get.failed", context={"error": str(e)})
return None
def set(
@@ -61,7 +61,7 @@ class CacheService:
serialized = json.dumps(value, default=str)
return self.client.setex(key, expire_seconds, serialized)
except Exception as e:
logger.warning(f"Cache set error: {e}")
logger.warning_event("Cache set error", event="cache.set.failed", context={"error": str(e)})
return False
def delete(self, key: str) -> bool:
@@ -69,7 +69,7 @@ class CacheService:
try:
return self.client.delete(key) > 0
except Exception as e:
logger.warning(f"Cache delete error: {e}")
logger.warning_event("Cache delete error", event="cache.delete.failed", context={"error": str(e)})
return False
def delete_pattern(self, pattern: str) -> int:
@@ -80,7 +80,7 @@ class CacheService:
return self.client.delete(*keys)
return 0
except Exception as e:
logger.warning(f"Cache delete_pattern error: {e}")
logger.warning_event("Cache delete_pattern error", event="cache.delete_pattern.failed", context={"error": str(e)})
return 0
def get_or_set(

161
backend/app/core/logging.py Normal file
View File

@@ -0,0 +1,161 @@
from __future__ import annotations
import json
import logging
import os
import re
from collections.abc import Mapping, Sequence
from typing import Any
from app.core.request_context import get_request_id
DEFAULT_SERVICE = "backend"
DEFAULT_EVENT = "app.log"
DEFAULT_LOG_LEVEL = os.getenv("PLANET_LOG_LEVEL", "INFO").upper()
REDACTED = "[REDACTED]"
SENSITIVE_FIELD_NAMES = {
"access_token",
"api_key",
"authorization",
"cookie",
"password",
"refresh_token",
"secret",
"token",
}
SENSITIVE_TEXT_PATTERNS = (
re.compile(r"(?i)(authorization\s*[:=]\s*)(.+)"),
re.compile(r"(?i)(bearer\s+)([A-Za-z0-9._\-]+)"),
re.compile(r"(?i)(token\s*[:=]\s*)(.+)"),
re.compile(r"(?i)(password\s*[:=]\s*)(.+)"),
re.compile(r"(?i)(cookie\s*[:=]\s*)(.+)"),
)
def sanitize_log_value(value: Any) -> Any:
if isinstance(value, Mapping):
return {
str(key): (REDACTED if str(key).lower() in SENSITIVE_FIELD_NAMES else sanitize_log_value(item))
for key, item in value.items()
}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [sanitize_log_value(item) for item in value]
if isinstance(value, str):
sanitized = value
for pattern in SENSITIVE_TEXT_PATTERNS:
sanitized = pattern.sub(lambda match: f"{match.group(1)}{REDACTED}", sanitized)
return sanitized
return value
def _normalize_context(context: Any) -> dict[str, Any]:
if context is None:
return {}
if isinstance(context, Mapping):
sanitized = sanitize_log_value(context)
return {str(key): value for key, value in sanitized.items()}
return {"value": sanitize_log_value(context)}
class PlanetContextFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = getattr(record, "request_id", None) or get_request_id() or "-"
record.service = getattr(record, "service", None) or DEFAULT_SERVICE
record.event = getattr(record, "event", None) or DEFAULT_EVENT
record.context = _normalize_context(getattr(record, "context", None))
record.message = sanitize_log_value(record.getMessage())
return True
class PlanetFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
timestamp = self.formatTime(record, self.datefmt)
level = record.levelname
service = getattr(record, "service", DEFAULT_SERVICE)
module_name = record.name
event = getattr(record, "event", DEFAULT_EVENT)
request_id = getattr(record, "request_id", "-")
message = sanitize_log_value(record.getMessage())
context = _normalize_context(getattr(record, "context", None))
context_suffix = ""
if context:
context_suffix = f" context={json.dumps(context, ensure_ascii=False, sort_keys=True)}"
rendered = (
f"{timestamp} {level} service={service} module={module_name} "
f"event={event} request_id={request_id} message={message}{context_suffix}"
)
if record.exc_info:
rendered = f"{rendered}\n{self.formatException(record.exc_info)}"
return rendered
class PlanetLoggerAdapter(logging.LoggerAdapter):
def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
extra = dict(self.extra)
extra.update(kwargs.get("extra", {}))
if "context" in extra:
extra["context"] = _normalize_context(extra.get("context"))
kwargs["extra"] = extra
return sanitize_log_value(msg), kwargs
def log_event(
self,
level: int,
message: str,
*,
event: str,
context: Mapping[str, Any] | None = None,
**extra: Any,
) -> None:
self.log(level, message, extra={"event": event, "context": context or {}, **extra})
def debug_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
self.log_event(logging.DEBUG, message, event=event, context=context, **extra)
def info_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
self.log_event(logging.INFO, message, event=event, context=context, **extra)
def warning_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
self.log_event(logging.WARNING, message, event=event, context=context, **extra)
def error_event(self, message: str, *, event: str, context: Mapping[str, Any] | None = None, **extra: Any) -> None:
self.log_event(logging.ERROR, message, event=event, context=context, **extra)
def exception_event(
self,
message: str,
*,
event: str,
context: Mapping[str, Any] | None = None,
**extra: Any,
) -> None:
self.error(message, exc_info=True, extra={"event": event, "context": context or {}, **extra})
def get_logger(name: str, *, service: str = DEFAULT_SERVICE) -> PlanetLoggerAdapter:
return PlanetLoggerAdapter(logging.getLogger(name), {"service": service})
def configure_logging(level: str | None = None) -> None:
root_logger = logging.getLogger()
if getattr(configure_logging, "_configured", False):
if level:
root_logger.setLevel(level.upper())
return
handler = logging.StreamHandler()
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
handler.addFilter(PlanetContextFilter())
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel((level or DEFAULT_LOG_LEVEL).upper())
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
target_logger = logging.getLogger(logger_name)
target_logger.handlers.clear()
target_logger.propagate = True
logging.captureWarnings(True)
configure_logging._configured = True

View File

@@ -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()

View File

@@ -5,10 +5,22 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sess
from sqlalchemy.orm import declarative_base
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__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,19 @@ 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_event(
"Database pool settings active",
event="database.pool.initialized",
context={
"pool_pre_ping": DB_POOL_CONFIG["pool_pre_ping"],
"pool_recycle": DB_POOL_CONFIG["pool_recycle"],
"pool_size": DB_POOL_CONFIG["pool_size"],
"max_overflow": DB_POOL_CONFIG["max_overflow"],
"pool_timeout": DB_POOL_CONFIG["pool_timeout"],
},
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@@ -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,8 @@ 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.logging import configure_logging
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 (
@@ -17,6 +20,9 @@ from app.services.scheduler import (
)
configure_logging()
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path.startswith("/ws") and request.method == "GET":
@@ -28,6 +34,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 +76,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(WebSocketCORSMiddleware)
app.include_router(api_router, prefix="/api/v1")

View File

@@ -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",
]

View File

@@ -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())

View File

@@ -46,6 +46,9 @@ class CelesTrakTLECollector(BaseCollector):
if response.status_code == 200:
data = response.json()
if isinstance(data, list):
for item in data:
if isinstance(item, dict):
item["_celestrak_group"] = group
all_satellites.extend(data)
print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
except Exception as e:
@@ -78,6 +81,7 @@ class CelesTrakTLECollector(BaseCollector):
"name": item.get("OBJECT_NAME", "Unknown"),
"reference_date": item.get("EPOCH", ""),
"metadata": {
"constellation_group": item.get("_celestrak_group"),
"norad_cat_id": item.get("NORAD_CAT_ID"),
"international_designator": item.get("OBJECT_ID"),
"epoch": item.get("EPOCH"),

View File

@@ -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,
}

View File

@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from app.core.logging import get_logger, sanitize_log_value
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 = get_logger(__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=str(sanitize_log_value(message)),
request_id=request_id or get_request_id(),
trace_id=trace_id,
user_id=user_id,
category=category,
context=sanitize_log_value(context or {}),
)
)
await session.commit()
except Exception:
logger.exception_event(
"Failed to persist system log",
event="system_log.persist.failed",
context={"event_name": event, "source": 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=sanitize_log_value(details or {}),
)
)
await session.commit()
except Exception:
logger.exception_event(
"Failed to persist audit log",
event="audit_log.persist.failed",
context={"action": action},
)

View File

@@ -1,7 +1,6 @@
"""Task Scheduler for running collection jobs."""
import asyncio
import logging
from datetime import UTC, datetime, timedelta
from typing import Any, Dict, Optional
@@ -9,13 +8,14 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from app.core.logging import get_logger
from app.db.session import async_session_factory
from app.core.time import to_iso8601_utc
from app.models.datasource import DataSource
from app.models.task import CollectionTask
from app.services.collectors.registry import collector_registry
logger = logging.getLogger(__name__)
logger = get_logger(__name__)
scheduler = AsyncIOScheduler()
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
@@ -54,7 +54,11 @@ async def _update_next_run_at(datasource: DataSource, session) -> None:
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
collector = collector_registry.get(datasource.source)
if not collector:
logger.warning("Collector not found for datasource %s", datasource.source)
logger.warning_event(
"Collector not found for datasource",
event="collector.schedule.collector_missing",
context={"collector_name": datasource.source},
)
return
collector_registry.set_active(datasource.source, datasource.is_active)
@@ -72,13 +76,17 @@ async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
replace_existing=True,
kwargs={"collector_name": datasource.source},
)
logger.info(
"Scheduled collector: %s (every %sm)",
datasource.source,
datasource.frequency_minutes,
logger.info_event(
"Scheduled collector",
event="collector.schedule.updated",
context={"collector_name": datasource.source, "frequency_minutes": datasource.frequency_minutes},
)
else:
logger.info("Collector disabled: %s", datasource.source)
logger.info_event(
"Collector disabled",
event="collector.schedule.disabled",
context={"collector_name": datasource.source},
)
await _update_next_run_at(datasource, session)
@@ -87,18 +95,30 @@ async def run_collector_task(collector_name: str):
"""Run a single collector task."""
collector = collector_registry.get(collector_name)
if not collector:
logger.error("Collector not found: %s", collector_name)
logger.error_event(
"Collector not found",
event="collector.run.collector_missing",
context={"collector_name": collector_name},
)
return
async with async_session_factory() as db:
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
datasource = result.scalar_one_or_none()
if not datasource:
logger.error("Datasource not found for collector: %s", collector_name)
logger.error_event(
"Datasource not found for collector",
event="collector.run.datasource_missing",
context={"collector_name": collector_name},
)
return
if not datasource.is_active:
logger.info("Skipping disabled collector: %s", collector_name)
logger.info_event(
"Skipping disabled collector",
event="collector.run.skipped_disabled",
context={"collector_name": collector_name},
)
return
running_result = await db.execute(
@@ -122,10 +142,10 @@ async def run_collector_task(collector_name: str):
and (now - started_at) > timedelta(minutes=RUNNING_TASK_GUARD_TIMEOUT_MINUTES)
)
if not is_stale:
logger.warning(
"Skipping collector %s trigger because task %s is already running",
collector_name,
existing_running.id,
logger.warning_event(
"Skipping collector trigger because task is already running",
event="collector.run.skipped_already_running",
context={"collector_name": collector_name, "task_id": existing_running.id},
)
return
@@ -143,31 +163,47 @@ async def run_collector_task(collector_name: str):
else stale_reason
)
await db.commit()
logger.warning(
"Marked stale running task %s as failed before rerun of %s",
existing_running.id,
collector_name,
logger.warning_event(
"Marked stale running task as failed before rerun",
event="collector.run.stale_task_failed",
context={"collector_name": collector_name, "task_id": existing_running.id},
)
try:
collector._datasource_id = datasource.id
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
logger.info_event(
"Running collector",
event="collector.run.started",
context={"collector_name": collector_name, "datasource_id": datasource.id},
)
task_result = await collector.run(db)
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = task_result.get("status")
await _update_next_run_at(datasource, db)
logger.info("Collector %s completed: %s", collector_name, task_result)
logger.info_event(
"Collector completed",
event="collector.run.completed",
context={"collector_name": collector_name, "datasource_id": datasource.id, "result": task_result},
)
except asyncio.CancelledError:
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "cancelled"
await db.commit()
logger.warning("Collector %s cancelled by operator", collector_name)
logger.warning_event(
"Collector cancelled by operator",
event="collector.run.cancelled",
context={"collector_name": collector_name, "datasource_id": datasource.id},
)
raise
except Exception as exc:
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "failed"
await db.commit()
logger.exception("Collector %s failed: %s", collector_name, exc)
logger.exception_event(
"Collector failed",
event="collector.run.failed",
context={"collector_name": collector_name, "datasource_id": datasource.id, "error": str(exc)},
)
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
@@ -194,7 +230,11 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
if stale_tasks:
await db.commit()
logger.warning("Cleaned up %s stale running collection task(s)", len(stale_tasks))
logger.warning_event(
"Cleaned up stale running collection tasks",
event="collector.cleanup.stale_tasks_cleaned",
context={"count": len(stale_tasks)},
)
return len(stale_tasks)
@@ -203,14 +243,14 @@ def start_scheduler() -> None:
"""Start the scheduler."""
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started")
logger.info_event("Scheduler started", event="scheduler.started")
def stop_scheduler() -> None:
"""Stop the scheduler."""
if scheduler.running:
scheduler.shutdown(wait=False)
logger.info("Scheduler stopped")
logger.info_event("Scheduler stopped", event="scheduler.stopped")
async def sync_scheduler_with_datasources() -> None:
@@ -271,12 +311,20 @@ def run_collector_now(collector_name: str) -> bool:
"""Run a collector immediately (not scheduled)."""
collector = collector_registry.get(collector_name)
if not collector:
logger.error("Collector not found: %s", collector_name)
logger.error_event(
"Collector not found",
event="collector.trigger.collector_missing",
context={"collector_name": collector_name},
)
return False
existing_task = get_running_collector_task(collector_name)
if existing_task is not None and not existing_task.done():
logger.warning("Collector %s is already running in-memory; skipping duplicate trigger", collector_name)
logger.warning_event(
"Collector is already running in-memory; skipping duplicate trigger",
event="collector.trigger.skipped_already_running",
context={"collector_name": collector_name},
)
return False
try:
@@ -289,10 +337,18 @@ def run_collector_now(collector_name: str) -> bool:
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
task.add_done_callback(_cleanup_task)
logger.info("Triggered collector: %s", collector_name)
logger.info_event(
"Triggered collector",
event="collector.trigger.started",
context={"collector_name": collector_name},
)
return True
except Exception as exc:
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
logger.error_event(
"Failed to trigger collector",
event="collector.trigger.failed",
context={"collector_name": collector_name, "error": str(exc)},
)
return False

View File

@@ -0,0 +1,532 @@
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,
)
CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
@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, *, allow_embedded: bool = True) -> str | None:
leading_match = LEADING_LEVEL_PATTERN.match(text)
if leading_match:
return normalize_log_level(leading_match.group(1))
if allow_embedded:
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 sanitize_text_log_line(line: str) -> str:
return CONTROL_CHAR_PATTERN.sub("", line)
def parse_text_log_entry(line: str) -> StructuredLogEntry:
sanitized_line = sanitize_text_log_line(line).rstrip("\n")
timestamp, remainder = parse_prefixed_timestamp(sanitized_line)
level = infer_log_level_from_text(remainder or sanitized_line, allow_embedded=False)
display_line = sanitized_line
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 sanitize_text_log_line(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],
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import logging
from io import StringIO
from app.core.logging import PlanetContextFilter, PlanetFormatter, get_logger
from app.core.request_context import set_request_id
def _capture_output(callback):
stream = StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(PlanetFormatter(datefmt="%Y-%m-%d %H:%M:%S"))
handler.addFilter(PlanetContextFilter())
adapter = get_logger("tests.logging")
target_logger = adapter.logger
original_handlers = list(target_logger.handlers)
original_level = target_logger.level
original_propagate = target_logger.propagate
target_logger.handlers = [handler]
target_logger.setLevel(logging.INFO)
target_logger.propagate = False
try:
callback(adapter)
finally:
handler.flush()
target_logger.handlers = original_handlers
target_logger.setLevel(original_level)
target_logger.propagate = original_propagate
return stream.getvalue()
def test_structured_logger_injects_request_id_and_event():
set_request_id("req-test-123")
try:
output = _capture_output(
lambda logger: logger.info_event(
"collector started",
event="collector.run.started",
context={"collector_name": "bgp_news"},
)
)
finally:
set_request_id(None)
assert "request_id=req-test-123" in output
assert "event=collector.run.started" in output
assert "service=backend" in output
assert '"collector_name": "bgp_news"' in output
def test_structured_logger_redacts_sensitive_text_and_context():
set_request_id("req-test-redact")
try:
output = _capture_output(
lambda logger: logger.error_event(
"Authorization: Bearer super-secret-token",
event="auth.token.failed",
context={
"token": "plain-secret",
"nested": {"password": "hunter2"},
"safe": "visible",
},
)
)
finally:
set_request_id(None)
assert "super-secret-token" not in output
assert "plain-secret" not in output
assert "hunter2" not in output
assert "[REDACTED]" in output
assert '"safe": "visible"' in output

View File

@@ -0,0 +1,217 @@
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"
def test_parse_text_log_entry_does_not_promote_exception_context_to_error():
line = "websockets.exceptions.ConnectionClosedError: sent 1011 (internal error) keepalive ping timeout"
entry = system_logs.parse_text_log_entry(line)
assert entry.level is None
def test_parse_text_log_entry_still_detects_explicit_error_prefix():
line = "ERROR: [Errno 98] Address already in use"
entry = system_logs.parse_text_log_entry(line)
assert entry.level == "error"
def test_read_log_snapshot_strips_nul_bytes_from_file_lines(tmp_path: Path, monkeypatch):
log_path = tmp_path / "backend.log"
log_path.write_bytes(
(
b"INFO: service booted\n"
b"ERROR: bind failed\n"
+ b"\x00" * 32
+ b"2026-04-23 23:41:32 INFO service=backend message=request served\n"
)
)
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)
assert snapshot is not None
assert snapshot["line_count"] == 3
assert snapshot["lines"] == [
"INFO: service booted",
"ERROR: bind failed",
"2026-04-23 23:41:32 INFO service=backend message=request served",
]

View File

@@ -8,6 +8,97 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.39.0] — 2026-04-24
## [0.40.1] — 2026-04-24
### 🔧 Improvements
- 卫星选中标记lockedring / lockeddot / 光晕)颜色统一跟随图例轨道倾角分类配色
- 修复 Starlink footprint 在特定视角下遮蔽卫星点的渲染顺序问题Group renderOrder 影响子 Mesh 排序)
- footprint 材质改为 `depthTest: false` + 相机朝向 limbFade替代 polygonOffset 深度竞争方案
- 修复选中海缆时误触发附近卫星高亮(该行为属于 BGP 事件点逻辑,不应用于海缆)
---
## [0.40.0] — 2026-04-24
### ✨ Highlights
- Earth 卫星 footprint 正式按星座能力分层Starlink 保留专用地表覆盖Iridium 改为独立外圈覆盖表达,其它非 Starlink 星座不再误用同一套 footprint
- Earth 卫星详情卡补齐覆盖能力与当前显示说明,用户现在可以直接看见每颗卫星为什么显示 footprint、为何回退为自身发光
### 🔧 Improvements
- 后端可视化接口新增并透传 `constellation_group``footprint_policy`,前端据此执行 capability-gated footprint renderer
- 新增 Iridium 独立 coverage ring adapter并继续保留 Starlink 专用 footprint 调校与昼夜可读性增强
- 新增 Earth 卫星 footprint 策略技术文档,明确 GNSS、generic LEO、GEO 与 Iridium 的显示边界
### 🐛 Fixes
- 修复前后端对 Iridium footprint policy 命名不一致,导致策略分发语义含混的问题
- 清理 Starlink footprint 渲染中的未使用常量与过时命名,减少后续继续调校时的歧义
---
## [0.39.0] — 2026-04-24
### ✨ Highlights
- 后端正式落下统一结构化日志地基:请求上下文、事件名、脱敏与持久化链路开始收口为可扩展的企业级日志体系
- 系统日志页重构为真正的日志工作台:顶部筛选更紧凑,终端日志区成为主视觉,移动端 Earth 新闻/态势细节交互继续补稳
### 🔧 Improvements
- 新增 `backend/app/core/logging.py`,统一 `request_id``service``event` 注入与敏感字段脱敏,并接入后端主入口、调度器、缓存、数据库和可视化链路
- 系统日志页筛选区重排为更紧凑的两层结构,信息摘要并入终端工具栏 tooltip日志终端区留出更稳定的按钮避让空间
- Earth 移动端态势抽屉补齐宽度约束与图例换行规则,新闻详情抽屉在巡航切换时可同步更新标题和摘要
### 🐛 Fixes
- 修复 `/tmp/planet_backend.log` 中混入空字节时,日志摘要条行数与实际可见日志不一致的问题
- 修复移动端“态势”tab 在内容渲染后被图例文本撑宽、超出一屏的问题
- 修复移动端新闻详情抽屉在巡航切换下一条新闻时标题更新但 summary 不同步的问题
---
## [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
- Earth 图层系统新增经纬线开关,桌面图层面板与移动端抽屉都可直接控制
### 🔧 Improvements
- 经纬线正式接入 Earth layer registry复用现有图层切换、移动端图层卡片与设置持久化流
### 🐛 Fixes
- 修复经纬线只能默认常驻、无法作为独立图层开关控制的问题
---
## [0.37.1] — 2026-04-23
### ✨ Highlights
- `planet.sh` 后端重启链路修复 `uvicorn --reload` 残留 worker 场景,`restart` 现在能真正替换旧实例
### 🔧 Improvements
- 收口后端清理逻辑,统一按 `uvicorn` 进程、端口占用进程和进程组执行清理,减少 reload 场景漏杀分支
### 🐛 Fixes
- 修复部分机器执行 `./planet.sh restart --allow-lan` 后后端仍停留旧实例,导致 `/api/v1/visualization/geo/compute-centers` 返回 `404` 的问题
---
## [0.35.1] — 2026-04-22
## [0.37.0] — 2026-04-23

View File

@@ -0,0 +1,793 @@
# Planet 企业级日志系统实施计划
## Goal
把 Planet 当前“能看一点运行输出”的日志能力,升级为一套真正可用、可定位、可纠错、可追责、可演进的企业级日志系统。
这里的“企业级”不是指一上来就接入很重的外部平台,而是指这套系统需要同时满足下面五件事:
1. 排障可用
2. 历史可查
3. 业务可解释
4. 权限操作可追责
5. 出错后能够反向定位到请求、任务、模块和操作者
最终目标不是“把更多 stdout 放到日志页里”,而是建立一套统一的日志契约与落地链路:
- 统一日志字段
- 统一事件命名
- 统一采集入口
- 统一查询视图
- 清晰的实时日志、持久化事件、审计日志分层
## Why
当前仓库已经有一些日志基础,但离真正可用的日志系统还有明显距离。
已有基础:
- 后端运行日志可通过 `/tmp/planet_backend.log` 查看
- 前端开发服务日志可通过 `/tmp/planet_frontend.log` 查看
- AI Provider 可从 Docker 容器读取日志
- Earth 浏览器端关键日志可上报到后端并进入 Redis 缓冲
- 已有 `system_logs` / `audit_logs` 持久化能力
- 管理台已有“系统日志”页面,支持来源、级别、日期、搜索
当前缺口:
- 后端日志仍以 `uvicorn` / 文本输出为主,不是统一结构化事件流
- 不同模块的日志格式不一致,很多地方只有 message没有 event 语义
- 还没有统一的后端 logger 封装与字段注入机制
- 前端虽然能上报错误,但还没有统一 logger API 和统一事件词汇
- Earth 与管理台之间的错误事件还没有形成可串联的事件链路
- 历史持久化还偏点状,很多高价值失败并没有系统性落库
- 系统日志页当前更像“运行输出查看器”,不是“多层日志查询台”
- 审计日志与运行日志尚未形成明确的产品级联动
所以当前真正的问题不是“有没有日志页”,而是:
**当前系统能看见输出,但还不能稳定回答“发生了什么、影响了谁、在哪条链路上坏了、是否已修复、是谁触发的”。**
## Current State
截至 2026-04-23当前代码中的日志相关能力大致如下。
### 1. 日志来源
当前系统日志页主要读取以下来源:
- `backend`
读取 `/tmp/planet_backend.log`
- `frontend`
读取 `/tmp/planet_frontend.log`
- `ai-provider`
读取 Docker 容器日志
- `earth-client`
读取 Redis 缓冲的浏览器端日志
这些来源定义在:
- [backend/app/services/system_logs.py](/home/ray/dev/linkong/planet/backend/app/services/system_logs.py)
### 2. 当前日志读取模型
当前 `read_log_snapshot()` 的职责是:
- 读取某个来源的最近若干行
- 解析基础级别与时间
- 按级别、日期、搜索进行过滤
- 返回用于日志页展示的快照
这个模型适合“运维查看器”,但不适合企业级日志系统,原因是:
- 读取基于文本尾部扫描,不是基于事件模型
- 不同来源的结构粒度完全不同
- 过滤依赖文本解析,准确率有限
- 没有请求、任务、用户、资源、动作等核心关联字段
### 3. 已有持久化能力
当前已经存在两个持久化入口:
- `record_system_log(...)`
- `record_audit_log(...)`
位置:
- [backend/app/services/persistent_logs.py](/home/ray/dev/linkong/planet/backend/app/services/persistent_logs.py)
这说明系统并不是从 0 开始,但也说明当前最大的问题是:
**持久化能力存在,但没有成为统一默认路径。**
### 4. 已有 request_id 基础
当前系统已具备 `request_id` 相关基础,部分持久化能力也会尝试写入 `request_id`
这为后续做:
- 请求链路排障
- 前后端关联查询
- 任务执行追踪
提供了很好的基础。
### 5. 当前日志页定位
当前日志页已经具备:
- 来源切换
- 级别筛选
- 日期筛选
- 搜索
- 文本控制台视图
但它仍然是“单层视图”:
- 上面是筛选器
- 下面是一块文本控制台
它还不是:
- 运行日志 + 事件日志 + 审计日志 的统一入口
- 也没有事件详情、关联跳转、纠错建议、链路追踪能力
## Core Principles
这套日志系统后续必须遵循下面几个原则。
### 1. 分层,而不是混存
日志必须拆成三层:
1. 运行日志
2. 持久化事件日志
3. 审计日志
它们的用途不同,绝不能继续混成一个概念。
#### 运行日志
用于:
- 实时排障
- 观察服务运行状态
- 看 stdout / stderr / exception / collector 输出
特点:
- 数据量大
- 时效性强
- 保留周期短
- 不要求每条都落库
#### 持久化事件日志
用于:
- 记录高价值错误
- 记录关键业务失败
- 支撑历史追溯
- 支撑趋势分析
特点:
- 只持久化有价值事件
- 必须结构化
- 必须有统一 event 命名
#### 审计日志
用于:
- 留痕
- 追责
- 还原高权限操作
特点:
- 必须单独建模
- 不与普通运行日志混用
### 2. 结构化优先
正式日志必须可拆字段,不能长期依赖自由文本。
最低要求至少能拿到:
- `timestamp`
- `level`
- `service`
- `module`
- `event`
- `message`
- `request_id`
- `trace_id`
- `user_id` / `actor`
- `context`
### 3. 事件命名优先于 message 命名
人看的 message 可以变化,但机器查询和跨模块关联必须依赖稳定事件名。
例如:
- `collector.run.started`
- `collector.run.completed`
- `collector.run.failed`
- `earth.layer.load_failed`
- `earth.cruise.route_build_failed`
- `system.restart_task.failed`
- `auth.websocket.invalid_token`
### 4. 查询链路必须可串联
企业级日志系统的核心不是“有很多日志”,而是“能串起来”。
最终一条高价值事件,至少要能回链到下面任意几类对象:
- 某个请求
- 某个任务
- 某个用户
- 某个数据源
- 某个 Earth 模块
- 某个管理动作
### 5. 默认脱敏
日志体系必须明确禁止记录:
- token
- password
- Authorization header
- cookie
- session
- 明文敏感个人信息
并且需要有统一脱敏器,而不是靠调用者自觉。
### 6. “可纠错”不是一句口号
这里的“可纠错”至少包含三层:
1. 日志字段足够解释错误,方便人排查
2. 系统能识别常见错误模式并给出纠偏建议
3. 关键错误支持闭环动作,例如重试、重建索引、重新触发采集、跳转到对应对象
也就是说,这套日志系统最终不只是“告诉你出错了”,而要尽量接近“告诉你为什么出错、怎么修、去哪修”。
## Non-Goals
第一阶段不追求:
- 全量接入 ELK / Loki / Datadog / OpenTelemetry 全家桶
- 做分布式 trace 全链路可视化大屏
- 把所有历史日志都迁进数据库
- 先做特别复杂的规则引擎
第一阶段追求的是:
- 在当前仓库和当前部署方式下,先把基础日志体系做正确
- 再为后续平台化接入预留好接口
## Target Architecture
推荐目标架构如下。
### Layer 1: Runtime Logs
职责:
- 承载后端、前端开发服务、容器输出、浏览器端缓冲事件
- 提供最近窗口内的实时查看能力
来源:
- 文件
- Docker
- Redis 缓冲
- 后续可扩展到 stdout collector
接口:
- `GET /api/v1/system/logs/sources`
- `GET /api/v1/system/logs/{source_id}`
这层继续保留,但需要做结构化增强和来源补强。
### Layer 2: Persistent System Events
职责:
- 只存高价值事件
- 供历史追溯、事件列表、趋势和纠错使用
数据来源:
- 后端关键异常
- 浏览器端关键失败
- 采集器/调度器关键失败
- 业务关键告警与降级事件
接口建议:
- `GET /api/v1/system/events`
- `GET /api/v1/system/events/{id}`
- `POST /api/v1/system/events/{id}/actions/...`(后续)
### Layer 3: Audit Logs
职责:
- 留痕高权限操作
- 记录操作者、对象、结果、请求号
接口建议:
- `GET /api/v1/system/audit-logs`
### Layer 4: Error Intelligence / Triage
职责:
- 对高频错误做归类
- 对已知错误给出解释与建议动作
- 对相同错误进行 fingerprint 聚合
这是“可纠错”能力的关键层。
建议字段:
- `fingerprint`
- `root_cause_type`
- `known_fix_hint`
- `runbook_url`
- `related_resource_type`
- `related_resource_id`
## Canonical Event Model
推荐统一事件字段模型如下。
### Runtime Log Record
```json
{
"timestamp": "2026-04-23T10:15:30Z",
"level": "error",
"service": "backend",
"module": "app.services.scheduler",
"event": "collector.run.failed",
"message": "Collector bgp_news failed",
"request_id": "req_xxx",
"trace_id": "trace_xxx",
"user_id": null,
"actor": null,
"resource_type": "collector",
"resource_id": "bgp_news",
"context": {
"datasource_id": 12,
"exception_type": "TimeoutError"
}
}
```
### Persistent System Event
```json
{
"id": 1024,
"event": "earth.layer.load_failed",
"level": "error",
"source": "earth-client",
"service": "earth",
"module": "cables",
"message": "Failed to load cable layer",
"fingerprint": "earth.layer.load_failed:cables:network_timeout",
"request_id": "req_xxx",
"trace_id": null,
"user_id": 1,
"resource_type": "earth_layer",
"resource_id": "cables",
"category": "visualization",
"status": "open",
"context": {
"url": "/api/v1/visualization/geo/cables"
},
"created_at": "2026-04-23T10:15:30Z"
}
```
### Audit Log
```json
{
"id": 88,
"action": "system.restart_task.requested",
"actor_id": 1,
"actor_name": "root",
"target_type": "restart_task",
"target_id": "restart_20260423_xxx",
"result": "success",
"request_id": "req_xxx",
"ip": "127.0.0.1",
"details": {
"action": "restart_backend"
},
"created_at": "2026-04-23T10:15:30Z"
}
```
## Implementation Plan
## Phase 0: Logging Inventory And Naming Freeze
目标:
- 先统一“记录什么”和“怎么命名”,避免后面越做越乱
工作项:
- 盘点当前所有 `logging.getLogger` 使用点
- 盘点裸 `print`
- 盘点 `record_system_log` / `record_audit_log` 已落点位
- 建立统一事件命名表
- 定义 service / module / category / resource 字段枚举
- 输出日志字段白名单和脱敏规范
完成标准:
- 有一份稳定的事件命名清单
- 有一份字段规范清单
- 后续新增日志不再“临时起名”
## Phase 1: Backend Structured Logging Foundation
目标:
- 把后端从“散落 logging + 文本输出”升级成“统一结构化 logger”
工作项:
- 新增统一后端 logger helper例如 `app/core/logging.py`
- 自动注入:
- `service`
- `module`
- `request_id`
- `trace_id`
- 增加统一脱敏 filter
- 把关键模块先切到统一 logger
- API 层
- scheduler
- collectors
- websocket
- visualization
- system control
- 约束:
- 正式路径禁止裸 `print`
- 正式异常优先 `logger.exception(..., extra={...})`
完成标准:
- 后端关键模块都有稳定 `event`
- request 日志和异常日志能挂上 `request_id`
- 不再依赖只看 `uvicorn` 原生文本输出来定位问题
## Phase 2: Persistent Event Layer
目标:
- 把“值得长期保留的错误和关键事件”系统性落库
工作项:
- 重新定义 `record_system_log()` 的使用边界
- 明确哪些事件必须持久化:
- API 关键失败
- 调度器失败
- 采集器失败
- Earth 客户端关键错误
- 数据源不可用
- 业务降级与恢复
- 补齐字段:
- `event`
- `resource_type`
- `resource_id`
- `category`
- `fingerprint`
- `status`
- 增加高频错误去重/聚合策略
完成标准:
- 高价值错误不再只存在于运行日志里
- 能查询最近一周/一月的关键失败事件
- 相同错误具备聚合基础
## Phase 3: Frontend And Earth Unified Logger
目标:
- 把前端从“点状 error 上报”升级成统一前端事件流
工作项:
- 在前端新增统一 logger API
- 统一方法:
- `debug`
- `info`
- `warn`
- `error`
- 统一字段:
- `page`
- `module`
- `event`
- `message`
- `url`
- `user_agent`
- `context`
- Earth 模块优先接入:
- layer load failed
- cruise build failed
- popup render failed
- connector render failed
- websocket dropped
- 管理台优先接入:
- settings save failed
- datasource toggle failed
- restart task submit failed
完成标准:
- 前端日志事件名与后端可对齐
- Earth 和管理台关键失败不再只停留在 console
- 浏览器端关键问题能进入统一系统日志/事件层
## Phase 4: Audit Logging Completion
目标:
- 把管理员与高权限操作真正做成企业级审计
工作项:
- 扩大审计覆盖面:
- 系统重启
- 数据源启停
- 调度规则变更
- 配置变更
- 人工触发采集
- 删除/修改关键配置
- 增加字段:
- actor
- target
- before / after
- request_id
- IP
- 审计页支持:
- 动作筛选
- 操作者筛选
- 时间筛选
- 目标对象筛选
完成标准:
- 所有高权限操作都能追到人、时间、对象、结果
## Phase 5: Log Console To Enterprise Observability UI
目标:
- 把当前“系统日志”页升级为真正的多层日志工作台
工作项:
- 将页面拆为三个主视图:
1. 运行日志
2. 关键事件
3. 审计日志
- 运行日志视图:
- 保留大控制台
- 支持来源、级别、日期、搜索
- 关键事件视图:
- 列表化展示高价值事件
- 支持聚合、状态、指纹、对象筛选
- 审计视图:
- 列表化展示管理员动作
- 增加详情抽屉:
- 原始 message
- context
- request_id
- related resource
- recommended action
完成标准:
- 日志页不再只是“终端文本窗口”
- 运维排障、历史追溯、审计留痕三者分层清晰
## Phase 6: Corrective Intelligence
目标:
- 让系统从“能看日志”进化到“能辅助修错”
工作项:
- 引入错误 fingerprint
- 对已知错误配置:
- 根因类型
- 修复建议
- runbook 链接
- 推荐动作
- 支持常见纠错动作:
- 重试采集任务
- 重载配置
- 跳转到对应模块/资源
- 打开相关日志过滤视图
- 高频错误支持聚合与静默窗口
完成标准:
- 已知错误能给出明确建议
- 运维不需要每次都从零猜
## Recommended Module Changes
### Backend
建议新增/增强的模块:
- `backend/app/core/logging.py`
- 统一 logger 封装
- formatter
- filter
- request/trace 注入
- `backend/app/services/persistent_logs.py`
- 扩展字段
- 统一持久化策略
- `backend/app/services/system_logs.py`
- 逐步从“文本尾部查看器”升级为“运行日志聚合器”
- `backend/app/services/log_classification.py`
- 指纹
- 根因分类
- 纠错建议
- `backend/app/api/v1/system_control.py`
- 补充事件 / 审计 / 日志多视图接口
### Frontend
建议新增/增强:
- `frontend/src/lib/logger.ts`
- 统一前端 logger API
- `frontend/src/pages/Logs/Logs.tsx`
- 升级为多层工作台
- `frontend/public/earth/js/...`
- 各 Earth 模块接入统一事件 logger
## Event Naming Convention
建议采用:
`<domain>.<resource>.<action>.<result>`
示例:
- `collector.datasource.run.started`
- `collector.datasource.run.failed`
- `earth.layer.cables.load.failed`
- `earth.cruise.route.build.failed`
- `system.restart_task.requested`
- `system.restart_task.completed`
- `auth.websocket.connect.failed`
- `settings.datasource.priority.updated`
规则:
- 不用自然语言句子
- 不把 ID 塞进 event 名里
- 资源对象通过字段承载,不通过 event 名承载
## Query Model
最终推荐支持的查询维度:
- 时间范围
- level
- source
- service
- module
- event
- request_id
- trace_id
- user_id / actor
- resource_type / resource_id
- category
- fingerprint
- status
- full-text search
## Retention Strategy
推荐保留策略:
- 运行日志:
- 文件 / 容器 / Redis 缓冲保留短周期
- 持久化事件:
- 保留中长期
- 审计日志:
- 长期保留
初版可以先这样:
- 运行日志7 到 14 天
- 关键事件90 到 180 天
- 审计日志180 天以上
后续再根据存储与合规要求调整。
## Security And Compliance
必须落实:
- 敏感字段脱敏
- 前端上报白名单
- 防止日志注入
- 审计日志不可被普通管理员随意篡改
- 高敏感纠错动作必须再次鉴权
## Success Criteria
当下面这些条件成立时,才算这套日志系统真的“成了”:
1. 一个后端请求失败时,能通过 `request_id` 在运行日志、持久化事件、审计日志之间串联查询
2. 一个 Earth 前端错误能定位到页面、模块、事件名和上下文
3. 一个采集器失败能同时看到运行日志、持久化事件和可执行纠错动作
4. 一个管理员操作能查到操作者、目标对象、结果和 request_id
5. 日志页不再只是文本控制台,而是完整的“运行日志 / 关键事件 / 审计日志”工作台
6. 高频已知错误能聚合并给出修复建议
## Delivery Order
推荐严格按下面顺序做,不要乱跳:
1. Phase 0 命名与字段规范冻结
2. Phase 1 后端结构化 logging 基础
3. Phase 2 高价值事件持久化
4. Phase 3 前端 / Earth 统一 logger
5. Phase 4 审计覆盖补齐
6. Phase 5 日志工作台 UI 重构
7. Phase 6 指纹 / 纠错 / runbook
原因:
- 如果不先统一字段和命名,后面 UI 和持久化会越来越乱
- 如果不先做后端结构化基础,前端上报再多也串不起来
- 如果不先补持久化层,就只有“实时可看”,没有“历史可查”
## First Actionable Milestone
如果要从明天就开始做,最合理的第一个里程碑是:
### M1: 让后端关键路径全部拥有统一结构化事件
范围:
- API 请求入口/出口
- scheduler
- collectors
- websocket
- visualization
- system control
交付物:
- 统一 logger helper
- 统一 event naming 表
- 统一 request_id 注入
- 统一脱敏策略
- 关键模块替换完成
完成这个里程碑后Planet 才算真正拥有了“企业级日志系统的地基”。

View File

@@ -11,6 +11,7 @@
- 前端上下文
- Earth 前端结构
- Earth 卫星 footprint 策略
- 后端运行控制
- collector 现状
- 采集格式约定

View File

@@ -0,0 +1,198 @@
# Earth Satellite Footprint Policy
本文件记录 Earth 卫星图层当前关于 `footprint` 的产品边界、资料依据和已落地实现,目标是避免把 Starlink 这套专用地表覆盖模型误用到其它星座上。
相关上下文:
- [earth-frontend-context.md](/home/ray/dev/linkong/planet/docs/technical/earth-frontend-context.md)
- [backend-collectors.md](/home/ray/dev/linkong/planet/docs/technical/backend-collectors.md)
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
## 当前目标
- 明确哪些非 Starlink 卫星不该显示贴地 footprint
- 明确哪些星座未来可以有独立 footprint但不能复用 Starlink bowtie / GSO-gap 模型
- 把这条策略沉淀成可执行实现边界,而不是继续散落在视觉参数里
## 本地实际类别
当前 CelesTrak 卫星分组在 [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py) 中包括:
- `starlink`
- `gps-ops`
- `galileo`
- `glonass`
- `beidou`
- `leo`
- `geo`
- `iridium-next`
其中非 Starlink 类别是:
- `gps-ops`
- `galileo`
- `glonass`
- `beidou`
- `leo`
- `geo`
- `iridium-next`
## 资料结论
### 1. GNSS / RNSS: `gps-ops`, `galileo`, `glonass`, `beidou`
默认不要画局部地表 footprint。
原因:
- 公开资料强调的是 `Earth-pointing``Earth coverage``continuous global coverage`
- 这类系统的公开语义是全球导航 / 授时覆盖,不是 Starlink 那种面向终端业务的局部 spot footprint
更合适的表示:
- 默认只显示卫星本体和轨道
- 如果后续要强调“服务可达性”,只能做很弱的 global coverage 语义,不应画贴地局部光斑
资料:
- [GPS III EC Antenna Patterns](https://www.navcen.uscg.gov/sites/default/files/pdf/gps/GPS_ZIP/GPS_III_EC_Antenna_Patterns_SVN_74_75_76_77_78.pdf)
- [ESA Galileo satellites](https://www.esa.int/Applications/Satellite_navigation/Galileo/Galileo_satellites)
- [Navipedia Galileo General Introduction](https://gssc.esa.int/navipedia/index.php/Galileo_General_Introduction)
- [BeiDou official overview](https://www.beidou.gov.cn/xt/gfxz/201812/P020190117356387956569.pdf)
- [GPS.gov GNSS overview](https://www.gps.gov/systems/gnss/)
### 2. `iridium-next`
可以有 footprint但不能复用 Starlink 的单一 bowtie footprint。
原因:
- Iridium NEXT 公开资料强调的是固定多 spot beam 体系
- 公开示例里常见的是 `48 fixed spot beams in 4 tiers`
- 这和 Starlink 当前这套“单星、单主 footprint、带 GSO 缺口”的业务可视化不是同一个问题
更合适的表示:
- 默认:仍然不画 Starlink 式地表 footprint
- 后续如果要做:单独接入 Iridium 多波束适配层
- 在视觉上更接近多束 cluster / 蜂窝 / 分层束,而不是单个 bowtie 光斑
资料:
- [Iridium Satellite Spot Beam Coverage on the US](https://www.mathworks.com/help/phased/ug/iridium-satellite-spot-beam-coverage-on-the-us-1.html)
### 3. `geo`
默认不要画统一 footprint。
原因:
- GEO 通信星公开上可能是 global beam、zone beam、spot beam、steerable spot beam
- 没有 operator / payload / beam contour 元数据时,统一画一个 footprint 很容易错
更合适的表示:
- 默认只显示 GEO belt 和卫星驻点语义
- 只有拿到 beam contour / operator metadata 时才允许画 footprint
资料:
- [ITU Handbook on Satellite](https://www.itu.int/dms_pub/itu-r/opb/hdb/R-HDB-42-2002-PDF-E.pdf)
### 4. `leo`generic
默认不要画 footprint。
原因:
- `leo` 组过于混杂,可能同时包含通信、遥感、试验、观测等不同任务
- 没有 mission / payload / antenna pattern 元数据时,无法判断是否存在可视化意义上的服务覆盖面
更合适的表示:
- 默认只显示卫星和轨道
- 后续如果按 operator / mission subtype 细分,再决定是否引入独立 coverage mode
## 产品策略
当前统一策略如下:
- `Starlink`
- 保留当前专用 `ground_footprint` 逻辑
- `Iridium NEXT`
- 预留独立适配层
- 当前不复用 Starlink footprint
- `GPS / Galileo / GLONASS / BeiDou`
- 不显示贴地 footprint
- `GEO`
- 无 beam metadata 不显示 footprint
- `generic LEO`
- 无 mission metadata 不显示 footprint
## 已落地实现
本次实现只做最小可执行版本,不改现有 Starlink 视觉参数:
1. 后端把星座分组和 footprint 策略提示透给前端
- CelesTrak collector 会把 `GROUP` 记入 `metadata.constellation_group`
- Visualization API 会输出:
- `properties.constellation_group`
- `properties.footprint_policy`
当前策略值:
- `starlink_ground_footprint`
- `iridium_coverage_ring`
- `none`
对应代码:
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py)
2. 前端把 footprint 变成 capability-gated renderer
- `ground_footprint` 只有在 `footprint_policy === starlink_ground_footprint` 时才真正启用
- `iridium-next` 不再回退成占位分支,而是走独立的 Iridium coverage ring adapter
- 其它非 Starlink 即使用户全局选择了 `ground_footprint`,也会自动回退到 `self_glow`
对应代码:
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
- [frontend/public/earth/js/iridium-footprint-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/iridium-footprint-adapter.js)
3. 卫星信息卡显示 capability而不是只显示轨道参数
- 卫星详情现在会明确显示:
- `星座/分组`
- `覆盖能力`
- `当前显示`
- `覆盖模型`
- 这样用户能直接看到:
- 当前卫星是否支持 footprint
- 当前显示是不是因为 capability gating 被回退
- Iridium 和 Starlink 使用的不是同一种模型
对应代码:
- [frontend/public/earth/js/main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js)
- [frontend/public/earth/js/info-card.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/info-card.js)
## 当前实现边界
这条边界需要继续保持:
- `Starlink` 的 footprint 参数和 shader 逻辑只服务于 Starlink
- 非 Starlink 的能力判断属于“策略层 / 适配层”
- 不要把不同星座的覆盖模型再混写进同一套参数里
- `iridium-next` 已经切成独立 adapter应继续沿这条边界演进而不是给现有 Starlink bowtie 增加更多 if/else
## 后续建议
如果继续往前做,推荐顺序是:
1.`iridium-next` 新建独立 footprint adapter
2. 在 UI 上补一个只读提示,让用户知道当前卫星是否支持 footprint
3. 如果未来拿到 GEO beam contour / operator metadata再为 GEO 开 operator-specific footprint

View File

@@ -16,12 +16,18 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.37.0`
- `dev` 当前开发分支历史推导到:`0.40.1`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.40.1` | improvement | `dev` | `pending` | 卫星选中标记配色跟随图例,修复 footprint 遮蔽卫星渲染问题,修复选中海缆误触发卫星高亮 |
| `0.40.0` | feature | `dev` | `pending` | Earth 卫星 footprint 按星座能力分层Iridium 独立 coverage ring 落地,卫星详情卡补齐覆盖能力与当前显示说明 |
| `0.39.0` | feature | `dev` | `pending` | 后端统一结构化日志地基落地,系统日志页重构为紧凑日志工作台,并修复 Earth 移动端态势抽屉与新闻详情同步问题 |
| `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统一桌面/移动端对象级锚点、临界区锚点滑动与稳定巡航展示链路 |
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.37.0",
"version": "0.40.1",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -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 {
@@ -502,6 +634,15 @@
gap: 12px;
}
.earth-mobile-drawer-slot--situation,
.earth-mobile-page,
.earth-mobile-stats-grid,
.earth-mobile-situation-card,
.earth-mobile-situation-legend-list {
width: 100%;
min-width: 0;
}
.earth-mobile-drawer-slot--situation.is-active {
display: grid;
}
@@ -615,6 +756,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 +783,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 +919,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 +943,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 +971,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;
@@ -818,11 +1033,28 @@
display: flex;
flex-direction: column;
gap: 8px;
overflow-x: hidden;
}
.earth-mobile-situation-status {
color: var(--hud-text);
line-height: 1.5;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.earth-mobile-situation-legend-list .legend-item,
.earth-mobile-situation-legend-list .legend-label {
min-width: 0;
}
.earth-mobile-situation-legend-list .legend-label {
white-space: normal;
overflow: visible;
text-overflow: clip;
overflow-wrap: anywhere;
word-break: break-word;
}
.earth-mobile-news-focus,
@@ -857,17 +1089,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 +1251,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 +1322,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 +1440,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 +1538,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 +1567,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 +2269,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%;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -118,6 +118,16 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="经纬线 graticule 经纬 latitude longitude">
<span class="material-symbols-rounded layer-row-icon">grid_4x4</span>
<div class="layer-row-copy">
<span class="layer-row-label">经纬线</span>
<span class="layer-row-meta">Graticule</span>
</div>
<button id="toggle-grid-lines" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换经纬线显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="卫星 satellites">
<span class="material-symbols-rounded layer-row-icon">satellite_alt</span>
<div class="layer-row-copy">
@@ -465,13 +475,41 @@
<div id="mobile-drawer-handle" class="earth-mobile-drawer-header">
<div class="earth-mobile-drawer-grabber" aria-hidden="true"></div>
</div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">图层</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">搜索</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">态势</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">新闻</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">TV</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">设置</button>
<div class="earth-mobile-drawer-nav">
<div class="earth-mobile-drawer-nav-copy">
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
<span class="earth-mobile-drawer-nav-title">模块切换</span>
</div>
<div class="earth-mobile-drawer-tabs-shell">
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--left" aria-hidden="true"></div>
<div class="earth-mobile-drawer-tabs-fade earth-mobile-drawer-tabs-fade--right" aria-hidden="true"></div>
<div class="earth-mobile-drawer-tabs" role="tablist" aria-label="移动端菜单">
<button class="earth-mobile-drawer-tab is-active" type="button" role="tab" data-drawer-card="layers" aria-selected="true">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">layers</span>
<span class="earth-mobile-drawer-tab-label">图层</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="search" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">search</span>
<span class="earth-mobile-drawer-tab-label">搜索</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="situation" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">travel_explore</span>
<span class="earth-mobile-drawer-tab-label">态势</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="news" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">article</span>
<span class="earth-mobile-drawer-tab-label">新闻</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="tv" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">live_tv</span>
<span class="earth-mobile-drawer-tab-label">TV</span>
</button>
<button class="earth-mobile-drawer-tab" type="button" role="tab" data-drawer-card="settings" aria-selected="false">
<span class="earth-mobile-drawer-tab-icon material-symbols-rounded" aria-hidden="true">tune</span>
<span class="earth-mobile-drawer-tab-label">设置</span>
</button>
</div>
</div>
</div>
<div class="earth-mobile-drawer-content">
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
@@ -579,13 +617,6 @@
<span class="earth-mobile-page-summary">移动端新闻直播和频道切换</span>
</div>
<select id="mobile-tv-source-select" class="earth-mobile-tv-select" aria-label="选择移动端新闻直播源"></select>
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
<div class="earth-mobile-tv-player">
<div id="mobile-tv-empty-state" class="earth-mobile-tv-empty">暂无可播放直播源,请先在系统配置中添加频道。</div>
<iframe
@@ -598,9 +629,32 @@
></iframe>
<video id="mobile-tv-video" class="earth-mobile-tv-video" hidden controls autoplay muted playsinline></video>
</div>
<div class="earth-mobile-tv-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn" type="button">刷新</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn" type="button">访问官网</button>
<div id="mobile-tv-overview" class="earth-mobile-tv-overview">
<div id="mobile-tv-overview-bar" class="earth-mobile-tv-overview-bar" role="button" tabindex="0" aria-expanded="false" aria-controls="mobile-tv-meta-wrap">
<div class="earth-mobile-tv-overview-copy">
<span class="earth-mobile-tv-overview-kicker">频道信息</span>
<span id="mobile-tv-overview-headline" class="earth-mobile-tv-overview-headline">暂无可用频道</span>
<span id="mobile-tv-overview-summary" class="earth-mobile-tv-overview-summary">展开查看当前频道来源、目录和补充说明</span>
<div id="mobile-tv-overview-tags" class="earth-mobile-tv-overview-tags" aria-label="频道摘要标签"></div>
</div>
<div class="earth-mobile-tv-overview-actions">
<button id="mobile-tv-refresh" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="刷新频道列表" title="刷新频道列表">
<span class="material-symbols-rounded" aria-hidden="true">refresh</span>
</button>
<button id="mobile-tv-open-external" class="earth-mobile-action-btn earth-mobile-action-btn--compact" type="button" aria-label="访问频道官网" title="访问频道官网">
<span class="material-symbols-rounded" aria-hidden="true">open_in_new</span>
</button>
</div>
</div>
<div id="mobile-tv-meta-wrap" class="earth-mobile-tv-meta-wrap">
<div class="earth-mobile-tv-meta">
<span id="mobile-tv-source-status" class="earth-mobile-tv-status">等待加载直播源</span>
<div id="mobile-tv-source-title" class="earth-mobile-tv-title">暂无可用频道</div>
<div id="mobile-tv-source-meta" class="earth-mobile-tv-subtitle">当前未配置可播放新闻直播源</div>
<div id="mobile-tv-source-catalog" class="earth-mobile-tv-catalog">频道目录待同步</div>
<div id="mobile-tv-source-notes" class="earth-mobile-tv-notes">支持后台配置默认源与采集器补充源。</div>
</div>
</div>
</div>
</div>
</section>
@@ -615,13 +669,36 @@
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">旋转模式</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按 BGP 事件轮播聚焦</span>
<span class="earth-mobile-settings-subtitle">巡航模式会按已启用模块的目标队列轮播聚焦</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择旋转模式">
<button type="button" class="earth-mobile-settings-pill is-active" data-rotation-mode="rotate" aria-pressed="true">旋转模式</button>
<button type="button" class="earth-mobile-settings-pill" data-rotation-mode="cruise" aria-pressed="false">巡航模式</button>
</div>
</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">巡航模块</span>
<span class="earth-mobile-settings-subtitle">选择哪些业务模块参与巡航队列。默认 BGP新闻可按需加入。</span>
</div>
<div class="earth-mobile-settings-chip-group" role="group" aria-label="移动端选择巡航模块">
<button type="button" class="earth-mobile-settings-chip is-active" data-cruise-module-toggle="bgp" aria-pressed="true">BGP</button>
<button type="button" class="earth-mobile-settings-chip" data-cruise-module-toggle="news" aria-pressed="false">新闻</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">卫星</div>
<div class="earth-mobile-settings-card earth-mobile-settings-card--stacked">
<div class="earth-mobile-settings-copy">
<span class="earth-mobile-settings-label">卫星显示风格</span>
<span class="earth-mobile-settings-subtitle">可选自身发光或真实地表覆盖两种选中表现</span>
</div>
<div class="earth-mobile-settings-segmented" role="group" aria-label="移动端选择卫星显示风格">
<button type="button" class="earth-mobile-settings-pill is-active" data-satellite-display-style="self_glow" aria-pressed="true">自身发光</button>
<button type="button" class="earth-mobile-settings-pill" data-satellite-display-style="ground_footprint" aria-pressed="false">真实地表覆盖</button>
</div>
</div>
</div>
<div class="earth-mobile-settings-group">
<div class="earth-mobile-settings-title">视图</div>
@@ -766,7 +843,7 @@
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">旋转模式</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按 BGP 事件轮播聚焦</span>
<span class="earth-settings-item-subtitle">旋转模式保持普通自转,巡航模式会按已启用模块的目标队列轮播聚焦</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择旋转模式">
<button
@@ -787,6 +864,54 @@
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">巡航模块</span>
<span class="earth-settings-item-subtitle">选择哪些业务模块参与巡航队列。默认 BGP新闻会按发生地与时间加入巡航目标并显示新闻卡片。</span>
</div>
<div class="earth-settings-chip-group" role="group" aria-label="选择巡航模块">
<button
type="button"
class="earth-settings-chip is-active"
data-cruise-module-toggle="bgp"
aria-pressed="true"
>
BGP
</button>
<button
type="button"
class="earth-settings-chip"
data-cruise-module-toggle="news"
aria-pressed="false"
>
新闻
</button>
</div>
</div>
<div class="earth-settings-item earth-settings-item--stacked">
<div class="earth-settings-copy">
<span class="earth-settings-item-title">卫星显示风格</span>
<span class="earth-settings-item-subtitle">选择卫星锁定态使用自身发光,还是强调真实地表覆盖范围。</span>
</div>
<div class="earth-settings-segmented" role="group" aria-label="选择卫星显示风格">
<button
type="button"
class="earth-settings-segmented-btn is-active"
data-satellite-display-style="self_glow"
aria-pressed="true"
>
自身发光
</button>
<button
type="button"
class="earth-settings-segmented-btn"
data-satellite-display-style="ground_footprint"
aria-pressed="false"
>
真实地表覆盖
</button>
</div>
</div>
</div>
</section>
<section class="earth-settings-section">

View File

@@ -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,
});
});
}

View File

@@ -18,6 +18,21 @@ export const ROTATION_MODE = {
CRUISE: "cruise",
};
export const CRUISE_MODULES = {
BGP: "bgp",
NEWS: "news",
};
export const DEFAULT_CRUISE_MODULES = [CRUISE_MODULES.BGP];
export const SATELLITE_DISPLAY_STYLES = {
SELF_GLOW: "self_glow",
GROUND_FOOTPRINT: "ground_footprint",
};
export const DEFAULT_SATELLITE_DISPLAY_STYLE =
SATELLITE_DISPLAY_STYLES.SELF_GLOW;
export const CRUISE_CONFIG = {
dwellMs: 7_000,
focusDurationMs: 1_400,
@@ -160,6 +175,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 = {

View File

@@ -1,9 +1,22 @@
// 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_SATELLITE_DISPLAY_STYLE,
DEFAULT_CRUISE_MODULES,
EARTH_CONFIG,
ROTATION_MODE,
SATELLITE_DISPLAY_STYLES,
} from "./constants.js";
import { setEarthStatValue, updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain, setDayNightEnabled } from "./earth.js";
import {
toggleTerrain,
setDayNightEnabled,
toggleGridLines,
getShowGridLines,
} from "./earth.js";
import { setCelestialDayNightEnabled } from "./celestial.js";
import {
ensureTerrainReady,
@@ -23,6 +36,8 @@ import {
toggleTrails,
getShowTrails,
getSatelliteCount,
getSatelliteDisplayStyle,
setSatelliteDisplayStyle as applySatelliteDisplayStyle,
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
@@ -104,6 +119,10 @@ let activeMobileDrawerId = null;
let mobileDrawerOpen = false;
let mobileDrawerCard = "layers";
let mobileDrawerHintTimer = null;
const ALLOWED_CRUISE_MODULES = new Set(Object.values(CRUISE_MODULES));
const ALLOWED_SATELLITE_DISPLAY_STYLES = new Set(
Object.values(SATELLITE_DISPLAY_STYLES),
);
function detectLayoutMode() {
const width = window.innerWidth;
@@ -628,6 +647,8 @@ function getCurrentPanelVisibilitySnapshot() {
function getCurrentSharedSettingsSnapshot() {
return {
rotationMode,
cruiseModules: getCruiseModules(),
satelliteDisplayStyle: getSatelliteDisplayStyle(),
layerVisibility: Object.fromEntries(
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
),
@@ -662,6 +683,9 @@ function cloneEarthSettings(settings) {
version: 2,
shared: {
rotationMode: settings.shared.rotationMode,
cruiseModules: [...(settings.shared.cruiseModules || DEFAULT_CRUISE_MODULES)],
satelliteDisplayStyle:
settings.shared.satelliteDisplayStyle || DEFAULT_SATELLITE_DISPLAY_STYLE,
terrainOpacity: settings.shared.terrainOpacity,
dayNightEnabled: settings.shared.dayNightEnabled,
defaultEarthZoom: settings.shared.defaultEarthZoom,
@@ -733,6 +757,19 @@ 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 nextSatelliteDisplayStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(
sharedSettings?.satelliteDisplayStyle,
)
? sharedSettings.satelliteDisplayStyle
: defaults.shared.satelliteDisplayStyle;
const nextTerrainOpacity = Number.parseFloat(sharedSettings?.terrainOpacity);
const nextDayNightEnabled = typeof sharedSettings?.dayNightEnabled === "boolean"
? sharedSettings.dayNightEnabled
@@ -745,6 +782,10 @@ function normalizeEarthSettings(rawSettings, defaults) {
version: 2,
shared: {
rotationMode: nextRotationMode,
cruiseModules: nextCruiseModules.length > 0
? nextCruiseModules
: [...DEFAULT_CRUISE_MODULES],
satelliteDisplayStyle: nextSatelliteDisplayStyle,
layerVisibility: normalizedLayerVisibility,
terrainOpacity: Number.isFinite(nextTerrainOpacity)
? nextTerrainOpacity
@@ -827,6 +868,126 @@ 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");
});
}
function syncSatelliteDisplayStyleControls() {
const activeStyle = getSatelliteDisplayStyle();
document.querySelectorAll("[data-satellite-display-style]").forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
const styleId = button.dataset.satelliteDisplayStyle || "";
const active = styleId === activeStyle;
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;
}
export function setSatelliteDisplayStyle(
nextStyle,
{ persist = true, suppressStatus = false } = {},
) {
const normalizedStyle = ALLOWED_SATELLITE_DISPLAY_STYLES.has(nextStyle)
? nextStyle
: DEFAULT_SATELLITE_DISPLAY_STYLE;
const previousStyle = getSatelliteDisplayStyle();
if (normalizedStyle === previousStyle) {
syncSatelliteDisplayStyleControls();
return normalizedStyle;
}
earthSettingsState = cloneEarthSettings(
earthSettingsState || cloneEarthSettings(captureEarthSettingsDefaults()),
);
earthSettingsState.shared.satelliteDisplayStyle = normalizedStyle;
applySatelliteDisplayStyle(normalizedStyle);
syncSatelliteDisplayStyleControls();
if (persist) {
persistEarthSettings();
}
if (!suppressStatus) {
const nextLabel =
normalizedStyle === SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT
? "真实地表覆盖"
: "自身发光";
showStatusMessage(`卫星显示风格已切换为:${nextLabel}`, "info");
}
return normalizedStyle;
}
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]");
@@ -889,6 +1050,11 @@ async function applyEarthSettings(settings) {
});
setRotationMode(settings.shared.rotationMode, { persist: false, suppressStatus: true });
setCruiseModules(settings.shared.cruiseModules, { persist: false, suppressStatus: true });
setSatelliteDisplayStyle(settings.shared.satelliteDisplayStyle, {
persist: false,
suppressStatus: true,
});
if (typeof settings.shared.dayNightEnabled === "boolean") {
applyDayNightEnabled(settings.shared.dayNightEnabled, { persist: false });
@@ -999,6 +1165,20 @@ async function setSatellitesLayerEnabled(button, enabled, { persist = true, sile
}
}
function setGridLinesLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleGridLines(enabled);
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏经纬线" : "显示经纬线",
});
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "经纬线已显示" : "经纬线已隐藏", "info");
}
return enabled;
}
function setBGPLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
toggleBGP(enabled);
@@ -1090,6 +1270,22 @@ function getBuiltinLayerDefinitions() {
setVisible: (visible, options = {}) =>
setTerrainEnabled(getLayerButton("terrain"), visible, options),
},
{
id: "gridLines",
buttonId: "toggle-grid-lines",
icon: "grid_4x4",
label: "经纬线",
meta: "Graticule",
keywords: "经纬线 graticule 经纬 latitude longitude",
defaultActive: true,
startupPriority: null,
startupMode: "visible",
startupLabel: "经纬线",
startupMessage: "",
getVisible: () => getShowGridLines(),
setVisible: (visible, options = {}) =>
setGridLinesLayerEnabled(getLayerButton("gridLines"), visible, options),
},
{
id: "satellites",
buttonId: "toggle-satellites",
@@ -1667,6 +1863,8 @@ 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 satelliteDisplayStyleButtons = document.querySelectorAll("[data-satellite-display-style]");
const syncTerrainOpacityUi = (nextOpacity) => {
const safeOpacity = Math.round(nextOpacity * 100);
terrainOpacitySliders.forEach((slider) => {
@@ -1721,6 +1919,34 @@ 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));
});
});
satelliteDisplayStyleButtons.forEach((button) => {
bindListener(button, "click", (event) => {
const target = event.currentTarget;
if (!(target instanceof HTMLButtonElement)) return;
const nextStyle = target.dataset.satelliteDisplayStyle;
if (!nextStyle) return;
setSatelliteDisplayStyle(nextStyle);
});
});
document.querySelectorAll("#toggle-daynight, [data-daynight-toggle]").forEach((dayNightToggle) => {
if (!(dayNightToggle instanceof HTMLInputElement)) return;
bindListener(dayNightToggle, "change", () => {
@@ -1737,6 +1963,8 @@ function setupSettingsControls() {
applyEarthSettings(loadEarthSettings());
syncAllHudPanelToggles();
syncRotationModeButtons();
syncCruiseModuleControls();
syncSatelliteDisplayStyleControls();
syncDayNightToggle(dayNightEnabled);
}

View File

@@ -7,6 +7,7 @@ import { latLonToVector3 } from './utils.js';
export let earth = null;
export let clouds = null;
export let terrain = null;
let showGridLines = true;
const textureLoader = new THREE.TextureLoader();
let _earthMaterial = null;
@@ -200,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,
@@ -238,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,
@@ -321,6 +320,7 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'latitude', value: lat };
line.visible = showGridLines;
earthObj.add(line);
latitudeLines.push(line);
}
@@ -335,11 +335,26 @@ export function createGridLines(scene, earthObj) {
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const line = new THREE.Line(geometry, gridMaterial);
line.userData = { type: 'longitude', value: lon };
line.visible = showGridLines;
earthObj.add(line);
longitudeLines.push(line);
}
}
export function toggleGridLines(visible) {
showGridLines = visible;
latitudeLines.forEach((line) => {
line.visible = visible;
});
longitudeLines.forEach((line) => {
line.visible = visible;
});
}
export function getShowGridLines() {
return showGridLines;
}
export function getEarth() {
return earth;
}

View File

@@ -3,6 +3,191 @@ 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 = `
<div class="info-card-news-layout">
<div class="info-card-news-kicker">NEWS SIGNAL</div>
<div class="info-card-news-title">${data?.title || '新闻事件'}</div>
<div class="info-card-news-summary-shell">
<div class="info-card-news-summary-label">SUMMARY</div>
<div class="info-card-news-summary" data-news-summary></div>
</div>
</div>
`;
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 = `
<div class="earth-mobile-news-detail">
<div class="earth-mobile-news-detail-kicker">NEWS SIGNAL</div>
<div class="earth-mobile-news-detail-title">${data?.title || '新闻事件'}</div>
<div class="earth-mobile-news-detail-summary-shell">
<div class="earth-mobile-news-detail-summary-label">SUMMARY</div>
<div class="earth-mobile-news-detail-summary" data-news-summary></div>
</div>
</div>
`;
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 += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
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 isMobileDetailsDrawerActive() {
const detailsSlot = document.querySelector('[data-drawer-slot="details"]');
return detailsSlot instanceof HTMLElement && detailsSlot.classList.contains('is-active');
}
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 += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
}
// ── Mobile popup ─────────────────────────────────────────────
@@ -12,6 +197,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 +211,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集群';
@@ -250,6 +437,10 @@ const CARD_CONFIG = {
fields: [
{ key: 'name', label: '名称' },
{ key: 'norad_id', label: 'NORAD ID' },
{ key: 'constellation', label: '星座/分组' },
{ key: 'footprint_capability', label: '覆盖能力' },
{ key: 'current_display', label: '当前显示' },
{ key: 'footprint_model', label: '覆盖模型' },
{ key: 'inclination', label: '倾角', unit: '°' },
{ key: 'period', label: '周期', unit: '分钟' },
{ key: 'perigee', label: '近地点', unit: 'km' },
@@ -280,6 +471,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 +821,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 +831,23 @@ 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 += `
<div class="earth-mobile-detail-row">
<span class="earth-mobile-detail-row-label">${field.label}</span>
<span class="earth-mobile-detail-row-value">${value}</span>
</div>
`;
}
content.innerHTML = html;
if (content && type !== 'news') {
renderMobileDetailContent(type, config, data);
renderedMobileDetailKey = null;
} else if (content && type === 'news' && isMobileDetailsDrawerActive()) {
renderMobileNewsCardContent(content, data);
renderedMobileDetailKey = getMobileDetailRenderKey(type, data);
}
// Show the floating mini popup near the touch point (requires coordinates)
@@ -667,37 +870,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 += `
<div class="info-card-property">
<span class="info-card-label">${field.label}</span>
<span class="info-card-value">${value}</span>
</div>
`;
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 +894,8 @@ export function hideInfoCard() {
new CustomEvent('earth:info-card-visibility-change', { detail: { visible: false } })
);
currentType = null;
pendingMobileDetailState = null;
renderedMobileDetailKey = null;
return;
}
hidePanel();

View File

@@ -0,0 +1,167 @@
import * as THREE from "three";
const EARTH_RADIUS_KM = 6378.137;
const SURFACE_SCALE = 1.003;
const SURFACE_OFFSET = 0.72;
const CLUSTER_DIAMETER_KM_APPROX = 4500;
const CLUSTER_RADIUS_KM_BASE = CLUSTER_DIAMETER_KM_APPROX / 2;
const SURFACE_AXIS = new THREE.Vector3(0, 0, 1);
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
material.dispose();
}
function disposeObjectTree(object) {
if (!object) return;
object.traverse((child) => {
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
disposeMaterial(child.material);
}
});
}
function createIridiumClusterMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3,
blending: THREE.AdditiveBlending,
uniforms: {
uColor: { value: new THREE.Color(0x5faeff) },
uOpacity: { value: 0.24 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
void main() {
vec2 p = vUv * 2.0 - 1.0;
float ellipseMetric = p.x * p.x * 0.82 + p.y * p.y * 1.06;
float alpha = exp(-ellipseMetric * 1.05) * (1.0 - smoothstep(0.86, 1.24, ellipseMetric));
alpha *= uOpacity;
if (alpha <= 0.001) discard;
gl_FragColor = vec4(uColor, alpha);
}
`,
});
}
function projectOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
alongKm,
crossKm,
earthRadiusWorld,
) {
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
const surfaceRadius = earthRadiusWorld * SURFACE_SCALE + SURFACE_OFFSET;
return centerNormal
.clone()
.multiplyScalar(earthRadiusWorld)
.addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
.addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
.normalize()
.multiplyScalar(surfaceRadius);
}
function computeClusterRadiusKm(altitudeKm) {
const altitudeScale = THREE.MathUtils.clamp(
(Number(altitudeKm) || 780) / 780,
0.88,
1.18,
);
return CLUSTER_RADIUS_KM_BASE * altitudeScale;
}
export function createIridiumFootprintAdapter({
earthObj,
earthRadiusWorld,
renderOrder,
}) {
if (!earthObj) return null;
const group = new THREE.Group();
group.name = "iridium-footprint-overlay";
group.renderOrder = renderOrder;
group.userData = {
earthRadiusWorld,
clusterGlow: null,
};
const clusterGlow = new THREE.Mesh(
new THREE.CircleGeometry(1, 72),
createIridiumClusterMaterial(),
);
clusterGlow.name = "iridium-cluster-glow";
clusterGlow.renderOrder = renderOrder - 1;
group.add(clusterGlow);
group.userData.clusterGlow = clusterGlow;
earthObj.add(group);
return group;
}
export function updateIridiumFootprintAdapter(
group,
{ position, alongTrack, crossTrack, altitudeKm },
) {
if (!group || !position || !alongTrack || !crossTrack) return;
const earthRadiusWorld =
group.userData?.earthRadiusWorld || EARTH_RADIUS_KM;
const centerNormal = position.clone().normalize();
const clusterRadiusKm = computeClusterRadiusKm(altitudeKm);
const clusterGlow = group.userData?.clusterGlow || null;
const worldUnitsPerKm = earthRadiusWorld / EARTH_RADIUS_KM;
if (clusterGlow) {
const clusterCenter = projectOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
0,
0,
earthRadiusWorld,
);
const clusterNormal = clusterCenter.clone().normalize();
clusterGlow.position.copy(clusterCenter);
clusterGlow.quaternion.setFromUnitVectors(SURFACE_AXIS, clusterNormal);
clusterGlow.scale.set(
clusterRadiusKm * worldUnitsPerKm * 1.18,
clusterRadiusKm * worldUnitsPerKm * 0.96,
1,
);
}
}
export function disposeIridiumFootprintAdapter(group, earthObj) {
if (!group) return;
if (earthObj) {
earthObj.remove(group);
} else if (group.parent) {
group.parent.remove(group);
}
disposeObjectTree(group);
}

View File

@@ -2,6 +2,7 @@ import * as THREE from "three";
import {
CONFIG,
CRUISE_MODULES,
HUD_CONFIG,
CABLE_CONFIG,
CABLE_STATE,
@@ -79,6 +80,7 @@ import {
getSatelliteCount,
selectSatellite,
getSatellitePoints,
getSatellitePresentationInfo,
setSatelliteRingState,
updateLockedRingPosition,
updateHoverRingPosition,
@@ -92,7 +94,9 @@ import {
updateBreathingPhase,
isSatelliteFrontFacing,
setSatelliteCamera,
setSatelliteSunDirection,
setLockedSatelliteIndex,
setHoveredSatelliteIndex,
resetSatelliteState,
clearSatelliteData,
} from "./satellites.js";
@@ -147,6 +151,7 @@ import {
import {
setupControls,
getAutoRotate,
getCruiseModules,
getRotationMode,
getShowTerrain,
getStartupLoadLayers,
@@ -167,6 +172,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 +188,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 +234,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();
@@ -472,6 +483,7 @@ function clearTransientHoverState() {
}
hoveredSatellite = null;
hoveredSatelliteIndex = null;
setHoveredSatelliteIndex(null);
}
function applyBGPHoverState(marker) {
@@ -568,6 +580,14 @@ function showSatelliteInfo(props, coords) {
const ecc = props?.eccentricity || 0;
const perigee = (6371 * (1 - ecc)).toFixed(0);
const apogee = (6371 * (1 + ecc)).toFixed(0);
const presentation = getSatellitePresentationInfo(props);
let footprintModel = "不适用";
if (presentation.footprintPolicy === "starlink_ground_footprint") {
footprintModel = "Starlink 单星地表覆盖";
} else if (presentation.footprintPolicy === "iridium_coverage_ring") {
footprintModel = "Iridium 外圈半透明覆盖";
}
setSelectedSatelliteLegend(props);
setLegendItems("satellites", getSatelliteLegendItems());
@@ -575,6 +595,10 @@ function showSatelliteInfo(props, coords) {
showInfoCard("satellite", {
name: props?.name || "-",
norad_id: props?.norad_cat_id,
constellation: presentation.constellationLabel,
footprint_capability: presentation.footprintCapabilityLabel,
current_display: presentation.presentationModeLabel,
footprint_model: footprintModel,
inclination: props?.inclination ? props.inclination.toFixed(2) : "-",
period,
perigee,
@@ -911,6 +935,9 @@ async function focusSearchSatellite(index) {
const satPositions = getSatellitePositions();
if (satPositions?.[index]) {
setSatelliteRingState(index, "locked", satPositions[index].current);
if (hoveredSatelliteIndex === index) {
setHoveredSatelliteIndex(index);
}
}
showSatelliteInfo(sat.properties, getSearchCardCoords());
showStatusMessage(`已定位卫星:${sat.properties.name || sat.properties.norad_cat_id || "未知卫星"}`, "info");
@@ -1042,7 +1069,9 @@ function resolveEarthSearchResults(query) {
icon: "satellite_alt",
typeLabel: "卫星",
title: props?.name || `NORAD ${props?.norad_cat_id || index}`,
subtitle: props?.norad_cat_id ? `NORAD ${props.norad_cat_id}` : "在轨卫星",
subtitle: props?.norad_cat_id
? `NORAD ${props.norad_cat_id} · ${getSatellitePresentationInfo(props).constellationLabel}`
: `${getSatellitePresentationInfo(props).constellationLabel} · 在轨卫星`,
score,
entity: { index },
});
@@ -1256,6 +1285,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 +1431,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 +1459,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 +1475,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 +1487,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 +1514,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 +1538,7 @@ async function pollCruiseEventsIfNeeded() {
await advanceCruiseEvent({ interrupt: true });
}
} catch (error) {
console.warn("巡航模式轮询 BGP 事件失败:", error);
console.warn("巡航模式轮询失败:", error);
}
}
@@ -1390,16 +1570,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 +1953,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 +2205,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 +2315,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 +2368,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 +2398,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);
@@ -2418,6 +2668,7 @@ function onMouseMove(event) {
);
}
}
setHoveredSatelliteIndex(hoveredSatelliteIndex);
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getSatelliteBriefHtml(hoveredSat.properties));
objectTooltipShown = true;
} else if (lockedObjectType === "bgp" && lockedObject) {
@@ -2717,19 +2968,6 @@ function onClick(event) {
lockedObject = clickedCable;
lockedObjectType = "cable";
setAutoRotate(false);
{
const cableLandingRegions = getLandingPoints()
.filter((lp) => lp.userData.cableNames?.includes(clickedCable.userData.name))
.map((lp) => {
const { lat, lon } = vector3ToLatLon(lp.position);
return { latitude: lat, longitude: lon };
});
const relatedSatelliteIndices = getRelatedSatelliteIndicesForRegions(
cableLandingRegions,
{ limit: 6, maxAngleDeg: 20 },
);
highlightRelatedSatellites(relatedSatelliteIndices, RELATED_SATELLITE_HIGHLIGHT_COLOR);
}
handleCableClick(clickedCable);
showCableInfo(clickedCable, { x: event.clientX, y: event.clientY });
return;
@@ -2784,6 +3022,9 @@ function onClick(event) {
"locked",
satPositions[selectedIndex].current,
);
if (hoveredSatelliteIndex === selectedIndex) {
setHoveredSatelliteIndex(selectedIndex);
}
}
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
@@ -2860,7 +3101,7 @@ function animate() {
applyCableVisualState();
const activeCruiseMarker =
isCruiseModeActive() && isCruisePresentationPinned()
? cruiseSequencer?.getCurrentItem() ?? null
? getCurrentCruiseBGPMarker()
: null;
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
@@ -2886,7 +3127,9 @@ function animate() {
updateBreathingPhase(deltaTime);
updateRelatedSatelliteHighlights();
updateCelestialLayer(new Date(), camera);
setEarthSunDirection(getSunDirection());
const currentSunDirection = getSunDirection();
setEarthSunDirection(currentSunDirection);
setSatelliteSunDirection(currentSunDirection);
updateNewsViewFocus(getCurrentViewCenterCoords());
const satPositions = getSatellitePositions();
if (

View File

@@ -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));
},
};
}

View File

@@ -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) {
? `<div class="news-story-summary">${item.summary}</div>`
: "";
return `
<a class="${cardClass}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<a class="${cardClass}" data-news-id="${item.id}" href="${item.url}" target="_blank" rel="noreferrer noopener">
<div class="news-story-meta">
<span class="news-story-source">${item.source}</span>
<span class="news-story-time">${formatRelativeTime(item.published_at)}</span>
@@ -169,6 +170,14 @@ function renderPayload(nextPayload) {
`;
})
.join("");
applyCruiseStorySelection();
window.dispatchEvent(new CustomEvent("earth:news-payload-updated", {
detail: {
payload: nextPayload,
itemIds: items.map((item) => item.id),
},
}));
}
async function fetchNews(lat, lon) {
@@ -287,6 +296,44 @@ export async function ensureNewsPanelReady() {
return payload;
}
export function getNewsPayload() {
return payload;
}
function updateBoardSelection(board, { scrollIntoView = false } = {}) {
if (!(board instanceof HTMLElement)) return;
const cards = board.querySelectorAll("[data-news-id]");
cards.forEach((card) => {
const matches = card.getAttribute("data-news-id") === selectedCruiseStoryId;
card.classList.toggle("news-story-card--cruise", matches);
if (matches && scrollIntoView) {
card.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
});
}
function applyCruiseStorySelection(options = {}) {
const desktopBoard = document.getElementById("news-board-list");
const mobileBoard = document.getElementById("mobile-news-board-list");
updateBoardSelection(desktopBoard, options);
updateBoardSelection(mobileBoard, options);
}
function revealSelectedCruiseStory() {
if (!selectedCruiseStoryId) return;
applyCruiseStorySelection({ scrollIntoView: true });
}
export function selectNewsItem(itemId, options = {}) {
selectedCruiseStoryId = itemId || null;
applyCruiseStorySelection(options);
}
export function clearSelectedNewsItem() {
selectedCruiseStoryId = null;
applyCruiseStorySelection();
}
export function initNewsPanel() {
if (initialized) return;
initialized = true;
@@ -296,9 +343,15 @@ export function initNewsPanel() {
window.addEventListener("earth:tv-tab-change", () => {
updateNewsToggleUI(isTVPanelVisible());
if (getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
});
window.addEventListener("earth:tv-visibility-change", (event) => {
updateNewsToggleUI(Boolean(event.detail?.visible));
if (event.detail?.visible && getActiveTVTab() === "news") {
revealSelectedCruiseStory();
}
});
["news-refresh", "mobile-news-refresh"].forEach((id) => {

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,7 @@ const tabPanelState = {
live: null,
news: null,
};
let mobileMetaCollapsed = false;
const META_AUTO_COLLAPSE_DELAY = 2500;
const PROBE_INTERVAL_MS = 2 * 60 * 1000;
@@ -72,8 +73,8 @@ function getElements() {
empty: document.getElementById(isMobile ? "mobile-tv-empty-state" : "tv-empty-state"),
refreshBtn: document.getElementById(isMobile ? "mobile-tv-refresh" : "tv-refresh"),
openBtn: document.getElementById(isMobile ? "mobile-tv-open-external" : "tv-open-external"),
metaWrap: document.getElementById("tv-meta-wrap"),
metaToggle: document.getElementById("tv-meta-toggle"),
metaWrap: document.getElementById(isMobile ? "mobile-tv-meta-wrap" : "tv-meta-wrap"),
metaToggle: document.getElementById(isMobile ? "mobile-tv-meta-toggle" : "tv-meta-toggle"),
liveHeaderControls: document.getElementById("tv-header-controls-live"),
newsHeaderControls: document.getElementById("tv-header-controls-news"),
liveTabBtn: document.getElementById("tv-tab-live"),
@@ -84,8 +85,43 @@ function getElements() {
};
}
function isMobileLayout() {
return document.body.classList.contains("layout-mode-mobile");
}
function syncMetaToggleState(collapsed) {
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
if (mobileOverviewBar instanceof HTMLElement) {
mobileOverviewBar.setAttribute("aria-expanded", collapsed ? "false" : "true");
}
const desktopToggle = document.getElementById("tv-meta-toggle");
if (desktopToggle instanceof HTMLButtonElement) {
desktopToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
desktopToggle.setAttribute(
"aria-label",
collapsed ? "展开新闻直播内容" : "折叠新闻直播内容",
);
desktopToggle.title = collapsed ? "展开新闻直播内容" : "折叠新闻直播内容";
}
}
function isMetaCollapsed() {
if (isMobileLayout()) {
return mobileMetaCollapsed;
}
return mediaPanel?.isCollapsed() ?? false;
}
function setMetaCollapsed(collapsed) {
if (isMobileLayout()) {
const { metaWrap } = getElements();
mobileMetaCollapsed = Boolean(collapsed);
metaWrap?.classList.toggle("is-collapsed", mobileMetaCollapsed);
syncMetaToggleState(mobileMetaCollapsed);
return;
}
mediaPanel?.setCollapsed(collapsed);
syncMetaToggleState(Boolean(collapsed));
}
function syncPanelActiveTab(tab = activeTab) {
@@ -120,6 +156,10 @@ function syncNewsDefaultMaxHeight() {
}
function autoExpandMeta() {
if (isMobileLayout()) {
clearTimeout(metaAutoCollapseTimer);
return;
}
clearTimeout(metaAutoCollapseTimer);
setMetaCollapsed(false);
metaAutoCollapseTimer = setTimeout(() => setMetaCollapsed(true), META_AUTO_COLLAPSE_DELAY);
@@ -154,7 +194,7 @@ function captureTabState(tab = activeTab) {
tabPanelState[tab] = {
layout: readPanelLayoutState(panel),
metaCollapsed:
tab === "live" ? (mediaPanel?.isCollapsed() ?? false) : null,
tab === "live" ? isMetaCollapsed() : null,
};
}
@@ -749,15 +789,59 @@ function getExternalUrl(source) {
}
function updateOpenButton(source) {
const { openBtn } = getElements();
if (!openBtn) return;
const targetUrl = getExternalUrl(source);
openBtn.disabled = !targetUrl;
openBtn.onclick = targetUrl
? () => {
window.open(targetUrl, "_blank", "noopener,noreferrer");
}
: null;
[
document.getElementById("mobile-tv-open-external"),
document.getElementById("tv-open-external"),
].forEach((button) => {
if (!(button instanceof HTMLButtonElement)) return;
button.disabled = !targetUrl;
button.onclick = targetUrl
? () => {
window.open(targetUrl, "_blank", "noopener,noreferrer");
}
: null;
});
}
function syncMobileOverviewSummary(source) {
const headline = document.getElementById("mobile-tv-overview-headline");
const summary = document.getElementById("mobile-tv-overview-summary");
const tags = document.getElementById("mobile-tv-overview-tags");
if (headline instanceof HTMLElement) {
headline.textContent = source?.name || "暂无可用频道";
}
if (summary instanceof HTMLElement) {
if (!source) {
summary.textContent = "点击查看当前频道来源、目录和补充说明";
} else {
const parts = [
source.provider,
source.region,
source.language,
].filter(Boolean);
summary.textContent = parts.length
? parts.join(" · ")
: "点击查看完整频道信息";
}
}
if (tags instanceof HTMLElement) {
const tagValues = source
? [
{ label: source.source_type || "频道", kind: "status" },
source.collector_source ? { label: `采集:${source.collector_source}`, kind: "" } : { label: "内置源", kind: "" },
source.region ? { label: source.region, kind: "" } : null,
].filter(Boolean).slice(0, 3)
: [{ label: "待加载", kind: "status" }];
tags.replaceChildren(
...tagValues.map(({ label, kind }) => {
const chip = document.createElement("span");
chip.className = `earth-mobile-tv-overview-tag${kind ? ` earth-mobile-tv-overview-tag--${kind}` : ""}`;
chip.textContent = label;
return chip;
}),
);
}
}
function findSourceById(sourceId) {
@@ -926,6 +1010,7 @@ function renderSource(source) {
if (notes) {
notes.textContent = source?.notes || "支持后台配置默认源与采集器补充源。";
}
syncMobileOverviewSummary(source);
if (!source || (!embeddedUrl && !videoUrl)) {
destroyHlsPlayer();
@@ -1031,6 +1116,7 @@ export function initTVPanel() {
liveTabBtn,
newsTabBtn,
} = getElements();
const mobileOverviewBar = document.getElementById("mobile-tv-overview-bar");
if (panel && metaToggle) {
mediaPanel = createHUDPanel({
@@ -1045,6 +1131,10 @@ export function initTVPanel() {
});
}
mobileMetaCollapsed = true;
syncMetaToggleState(isMetaCollapsed());
setMetaCollapsed(isMetaCollapsed());
updateToggleButton(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
syncSettingsToggle(mediaPanel?.isVisible() ?? !panel?.classList.contains("hud-panel-hidden"));
@@ -1084,10 +1174,31 @@ export function initTVPanel() {
});
});
metaToggle?.addEventListener("click", () => {
[metaToggle, document.getElementById("tv-meta-toggle")]
.filter((element, index, array) => element && array.indexOf(element) === index)
.forEach((toggleEl) => {
toggleEl?.addEventListener("click", () => {
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !isMetaCollapsed();
setMetaCollapsed(isNowCollapsed);
});
});
const toggleMobileMeta = (event) => {
const interactiveTarget = event.target instanceof Element
? event.target.closest("button, a, select, input, textarea, video, iframe")
: null;
if (interactiveTarget) return;
clearTimeout(metaAutoCollapseTimer);
const isNowCollapsed = !(mediaPanel?.isCollapsed() ?? false);
const isNowCollapsed = !isMetaCollapsed();
setMetaCollapsed(isNowCollapsed);
};
mobileOverviewBar?.addEventListener("click", toggleMobileMeta);
mobileOverviewBar?.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggleMobileMeta(event);
});
[refreshBtn, document.getElementById("mobile-tv-refresh"), document.getElementById("tv-refresh")]

View File

@@ -17,6 +17,7 @@ const Earth = lazy(() => import('./pages/Earth/Earth'))
const Settings = lazy(() => import('./pages/Settings/Settings'))
const BGP = lazy(() => import('./pages/BGP/BGP'))
const Playground = lazy(() => import('./pages/Playground/Playground'))
const Logs = lazy(() => import('./pages/Logs/Logs'))
function App() {
const { token } = useAuthStore()
@@ -48,6 +49,7 @@ function App() {
<Route path="/alerts/situational" element={<SituationalAlerts />} />
<Route path="/bgp" element={<BGP />} />
<Route path="/playground" element={<Playground />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>

View File

@@ -15,6 +15,7 @@ import {
AppstoreOutlined,
ToolOutlined,
InboxOutlined,
FileTextOutlined,
} from '@ant-design/icons'
import { useLocation, useNavigate } from 'react-router-dom'
import type { ItemType, MenuItemType } from 'antd/es/menu/interface'
@@ -39,6 +40,7 @@ function AppLayout({ children }: AppLayoutProps) {
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
const showBanner = true
const appVersion = `v${packageJson.version}`
const isSuperAdmin = user?.role === 'super_admin'
const menuItems: ItemType<MenuItemType>[] = [
{
@@ -83,6 +85,7 @@ function AppLayout({ children }: AppLayoutProps) {
label: '运维与配置',
children: [
{ key: '/playground', icon: <RobotOutlined />, label: 'AI Playground' },
...(isSuperAdmin ? [{ key: '/logs', icon: <FileTextOutlined />, label: '系统日志' }] : []),
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
],

View File

@@ -3414,8 +3414,365 @@ body {
max-height: 180px;
}
.system-log-console {
flex: 1 1 auto;
min-height: 0;
height: 100%;
position: relative;
border-radius: 16px;
background: #020617;
border: 1px solid rgba(148, 163, 184, 0.18);
overflow: hidden;
}
.system-log-console__actions {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 2px;
padding-inline: 6px;
}
.system-log-console__actions .ant-btn {
color: rgba(226, 232, 240, 0.78) !important;
}
.system-log-console__actions .ant-btn:hover {
color: #f8fafc !important;
background: rgba(148, 163, 184, 0.16) !important;
}
.system-log-console__scroll,
.system-log-console__scroll .scrollbar__viewport {
height: 100%;
}
.system-log-console__scroll,
.system-log-console__scroll .scrollbar__viewport,
.system-log-console__content,
.system-log-console__placeholder {
min-height: 100%;
}
.system-log-console__content {
margin: 0;
padding: 18px 124px 18px 20px;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.system-log-console__placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.logs-page {
--logs-filter-toggle-size: 32px;
height: 100%;
min-height: 0;
gap: 10px;
}
.logs-page__header-copy {
min-width: 0;
}
.logs-page .page-shell__header {
gap: 8px;
}
.logs-page__header-desc {
line-height: 1.4;
}
.logs-page__card {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.logs-page__card .ant-card-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 14px;
}
.logs-page__card-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.logs-page__console-shell {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.logs-page__toolbar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 16px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 249, 252, 0.98) 100%);
border: 1px solid rgba(5, 5, 5, 0.07);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
}
.logs-page__toolbar-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.logs-page__toolbar-row--primary {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(220px, 280px) minmax(280px, 1fr) var(--logs-filter-toggle-size);
align-items: center;
}
.logs-page__source-select {
width: 240px;
min-width: 220px;
}
.logs-page__search-input {
width: 100%;
min-width: 240px;
}
.logs-page__level-select {
width: 100%;
min-width: 220px;
}
.logs-page__date-range {
width: 248px;
}
.logs-page__line-limit-select {
width: 156px;
}
.logs-page__filter-toggle {
display: inline-flex;
justify-content: center;
align-items: center;
align-self: center;
width: var(--logs-filter-toggle-size);
height: var(--logs-filter-toggle-size);
padding: 0;
border: 1px solid rgba(5, 5, 5, 0.08);
border-radius: 8px;
background: transparent;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.logs-page__filter-toggle:hover {
color: rgba(0, 0, 0, 0.88);
border-color: rgba(5, 5, 5, 0.16);
background: rgba(0, 0, 0, 0.02);
}
.logs-page__filter-toggle.is-expanded {
color: #1677ff;
border-color: rgba(22, 119, 255, 0.28);
background: rgba(22, 119, 255, 0.06);
}
.logs-page__filters-panel {
border-top: 1px solid rgba(5, 5, 5, 0.06);
padding-top: 8px;
}
.logs-page__toolbar-row--secondary {
display: grid;
grid-template-columns: 156px minmax(260px, 320px) minmax(0, 1fr);
align-items: start;
}
.logs-page__preset-group {
display: flex;
align-self: center;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
}
.logs-page__preset-group .ant-space-item {
display: flex;
align-items: center;
}
.logs-page__preset-group .ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
}
.logs-page__calendar-cell {
position: relative;
width: 100%;
height: 100%;
}
.logs-page__calendar-cell .ant-picker-cell-inner {
position: relative;
transition: background 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
}
.logs-page__calendar-cell--error .ant-picker-cell-inner {
background: rgba(239, 68, 68, 0.12);
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.16);
}
.logs-page__calendar-cell--warning .ant-picker-cell-inner {
background: rgba(245, 158, 11, 0.12);
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.16);
}
.logs-page__calendar-cell--info .ant-picker-cell-inner {
background: rgba(34, 197, 94, 0.12);
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.16);
}
.logs-page__calendar-cell--debug .ant-picker-cell-inner {
background: rgba(148, 163, 184, 0.12);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.16);
}
.logs-page__calendar-cell:hover .ant-picker-cell-inner {
filter: saturate(1.04);
}
.logs-page__line-limit-customizer {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid rgba(5, 5, 5, 0.06);
}
@media (max-width: 1440px), (max-height: 900px) {
.logs-page {
gap: 8px;
}
.logs-page__header-desc {
display: none;
}
.logs-page__card .ant-card-body {
padding: 12px;
}
.logs-page__toolbar {
padding: 8px 10px;
gap: 6px;
}
.logs-page__toolbar-row--primary {
grid-template-columns: minmax(160px, 200px) minmax(180px, 220px) minmax(0, 1fr) var(--logs-filter-toggle-size);
grid-template-areas:
"source level search toggle";
row-gap: 8px;
}
.logs-page__source-select,
.logs-page__line-limit-select,
.logs-page__level-select,
.logs-page__search-input {
width: 100%;
min-width: 0;
}
.logs-page__source-select {
grid-area: source;
}
.logs-page__level-select {
grid-area: level;
}
.logs-page__search-input {
grid-area: search;
}
.logs-page__filter-toggle {
grid-area: toggle;
}
.logs-page__toolbar-row--secondary {
grid-template-columns: 140px minmax(240px, 280px) minmax(0, 1fr);
gap: 8px;
}
.logs-page__date-range {
width: 100%;
min-width: 0;
}
.logs-page__console-shell {
min-height: clamp(340px, 58vh, 760px);
}
.system-log-console__content {
padding: 16px 112px 16px 16px;
}
}
@media (max-width: 768px) {
.dashboard-restart-toolbar__meta {
grid-template-columns: 1fr;
}
.logs-page__toolbar {
flex-direction: column;
align-items: stretch;
}
.logs-page__card .ant-card-body {
padding: 10px;
}
.logs-page__toolbar-row--primary,
.logs-page__toolbar-row--secondary {
grid-template-columns: 1fr;
align-items: stretch;
}
.logs-page__source-select,
.logs-page__search-input,
.logs-page__level-select,
.logs-page__date-range,
.logs-page__line-limit-select {
width: 100%;
}
.logs-page__console-shell {
min-height: clamp(280px, 50vh, 620px);
}
}

View File

@@ -0,0 +1,533 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Card, DatePicker, Empty, Input, InputNumber, Select, Space, Spin, Tag, Tooltip, Typography, message } from 'antd'
import { CopyOutlined, DownOutlined, InfoCircleOutlined, ReloadOutlined, UpOutlined } from '@ant-design/icons'
import axios from 'axios'
import dayjs, { Dayjs } from 'dayjs'
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { useAuthStore } from '../../stores/auth'
const { Paragraph, Text, Title } = Typography
const { RangePicker } = DatePicker
const LOG_FILTER_STORAGE_KEY = 'planet.logs.filters'
const DATE_PRESET_OPTIONS = [
{ key: 'today', label: 'Today', days: 0 },
{ key: 'last3', label: '3 Days', days: 2 },
{ key: 'last7', label: '7 Days', days: 6 },
] as const
interface LogSourceSummary {
source_id: string
name: string
kind: string
location: string
description: string
category: string
status: string
}
interface LogSourcesResponse {
items: LogSourceSummary[]
}
interface LogSnapshot {
source_id: string
name: string
kind: string
location: string
description: string
category: string
status: string
level: string
selected_levels: string[]
search_query: string
available_levels: string[]
daily_markers: Array<{
date_token: string
total: number
dominant_level: 'error' | 'warning' | 'info' | 'debug'
}>
line_limit: number
line_count: number
lines: string[]
}
interface DailyLogMarker {
total: number
dominantLevel: 'error' | 'warning' | 'info' | 'debug'
}
const LOG_LIMIT_OPTIONS = [100, 200, 400, 800]
const LOG_LEVEL_OPTIONS = [
{ value: 'error', label: 'ERROR' },
{ value: 'warning', label: 'WARNING' },
{ value: 'info', label: 'INFO' },
{ value: 'debug', label: 'DEBUG' },
]
function isDayjsValue(value: unknown): value is Dayjs {
return dayjs.isDayjs(value)
}
function normalizeSelectedLevels(levels: string[] | null | undefined): string[] {
const allowedLevels = new Set(LOG_LEVEL_OPTIONS.map((item) => item.value))
return Array.from(new Set((levels || []).filter((level) => allowedLevels.has(level))))
}
function getLogLevelTagColor(level: string): string {
if (level === 'error') return 'error'
if (level === 'warning') return 'warning'
if (level === 'info') return 'success'
if (level === 'debug') return 'default'
return 'default'
}
function readStoredFilters() {
if (typeof window === 'undefined') {
return null
}
try {
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
if (!rawValue) return null
const parsed = JSON.parse(rawValue) as {
selectedSource?: string
lineLimit?: number
selectedLevels?: string[]
selectedDateRange?: [string, string] | null
searchQuery?: string
}
return parsed
} catch {
return null
}
}
function getStatusLabel(status: string): string {
if (status === 'ok') return '可用'
if (status === 'missing') return '暂无日志'
if (status === 'empty') return '暂无上报'
if (status === 'docker_unavailable') return 'Docker 不可用'
if (status === 'source_unavailable') return '日志源不可用'
return status
}
function getStatusHelp(status: string): string | null {
if (status === 'missing') return '当前日志文件尚未生成,通常需要先启动对应服务。'
if (status === 'empty') return '当前日志源还没有收到任何上报事件。'
if (status === 'docker_unavailable') return '当前环境没有可用的 docker 命令,暂时无法读取容器日志。'
if (status === 'source_unavailable') return '日志源当前不可读取,请检查服务是否已启动。'
return null
}
function resolvePresetRange(days: number): [Dayjs, Dayjs] {
const end = dayjs().endOf('day')
const start = dayjs().subtract(days, 'day').startOf('day')
return [start, end]
}
function normalizeDateRange(
range: [Dayjs | null, Dayjs | null] | null,
): [Dayjs | null, Dayjs | null] | null {
if (!range?.[0] || !range?.[1]) return null
return [range[0].startOf('day'), range[1].endOf('day')]
}
function getActiveDatePreset(range: [Dayjs | null, Dayjs | null] | null): string | null {
if (!range?.[0] || !range?.[1]) return null
for (const option of DATE_PRESET_OPTIONS) {
const [presetStart, presetEnd] = resolvePresetRange(option.days)
if (range[0].isSame(presetStart, 'day') && range[1].isSame(presetEnd, 'day')) {
return option.key
}
}
return null
}
function Logs() {
const storedFilters = readStoredFilters()
const { user } = useAuthStore()
const isSuperAdmin = user?.role === 'super_admin'
const [sources, setSources] = useState<LogSourceSummary[]>([])
const [selectedSource, setSelectedSource] = useState<string>(storedFilters?.selectedSource || 'backend')
const [lineLimit, setLineLimit] = useState<number>(storedFilters?.lineLimit || 200)
const [selectedLevels, setSelectedLevels] = useState<string[]>(
normalizeSelectedLevels(storedFilters?.selectedLevels),
)
const [selectedDateRange, setSelectedDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(
storedFilters?.selectedDateRange
? normalizeDateRange([dayjs(storedFilters.selectedDateRange[0]), dayjs(storedFilters.selectedDateRange[1])])
: null,
)
const [searchQuery, setSearchQuery] = useState<string>(typeof storedFilters?.searchQuery === 'string' ? storedFilters.searchQuery : '')
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
const [sourcesLoading, setSourcesLoading] = useState(false)
const [logLoading, setLogLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [filtersExpanded, setFiltersExpanded] = useState(
Boolean(
storedFilters?.selectedLevels?.length
|| (storedFilters?.selectedDateRange?.[0] && storedFilters?.selectedDateRange?.[1]),
),
)
const [messageApi, contextHolder] = message.useMessage()
const fetchSources = async () => {
setSourcesLoading(true)
try {
const res = await axios.get<LogSourcesResponse>('/api/v1/system/logs/sources')
setSources(res.data.items)
setErrorMessage(null)
if (res.data.items.length > 0 && !res.data.items.some((item) => item.source_id === selectedSource)) {
setSelectedSource(res.data.items[0].source_id)
}
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setErrorMessage(typeof detail === 'string' ? detail : '加载日志源失败')
} finally {
setSourcesLoading(false)
}
}
const fetchSnapshot = async (
sourceId: string,
limit: number,
levels: string[],
dateRange: [Dayjs | null, Dayjs | null] | null,
searchValue: string,
) => {
setLogLoading(true)
try {
const res = await axios.get<LogSnapshot>(`/api/v1/system/logs/${sourceId}`, {
params: {
limit,
level: levels.length === 1 ? levels[0] : 'all',
levels: levels.length > 0 ? levels.join(',') : undefined,
start_date: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
end_date: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
search: searchValue.trim() || undefined,
},
})
setSnapshot(res.data)
setErrorMessage(null)
} catch (error) {
const detail = axios.isAxiosError(error) ? error.response?.data?.detail : null
setSnapshot(null)
setErrorMessage(typeof detail === 'string' ? detail : '加载日志内容失败')
} finally {
setLogLoading(false)
}
}
useEffect(() => {
if (!isSuperAdmin) return
fetchSources()
}, [isSuperAdmin])
useEffect(() => {
if (!isSuperAdmin || !selectedSource) return
fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
}, [isSuperAdmin, selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery])
useEffect(() => {
if (typeof window === 'undefined') return
window.localStorage.setItem(
LOG_FILTER_STORAGE_KEY,
JSON.stringify({
selectedSource,
lineLimit,
selectedLevels,
selectedDateRange:
selectedDateRange?.[0] && selectedDateRange?.[1]
? [
selectedDateRange[0].format('YYYY-MM-DD'),
selectedDateRange[1].format('YYYY-MM-DD'),
]
: null,
searchQuery,
}),
)
}, [lineLimit, searchQuery, selectedLevels, selectedDateRange, selectedSource])
if (!isSuperAdmin) {
return (
<AppLayout>
<Alert type="warning" showIcon message="仅超级管理员可查看系统日志" />
</AppLayout>
)
}
const selectedMeta = sources.find((item) => item.source_id === selectedSource)
const statusHelp = getStatusHelp(snapshot?.status || selectedMeta?.status || '')
const activeDatePreset = getActiveDatePreset(selectedDateRange)
const dailyLogMarkers = useMemo(
() =>
new Map<string, DailyLogMarker>(
(snapshot?.daily_markers || []).map((marker) => [
marker.date_token,
{
total: marker.total,
dominantLevel: marker.dominant_level,
},
]),
),
[snapshot?.daily_markers],
)
const currentResultLines = snapshot?.lines || []
const lineCountLabel = currentResultLines.length
const hasDateFilter = Boolean(selectedDateRange?.[0] && selectedDateRange?.[1])
const hasAdvancedFilters = selectedLevels.length > 0 || hasDateFilter
const effectiveLevelLabels = selectedLevels.length === 0
? ['ALL']
: normalizeSelectedLevels(selectedLevels).map(
(level) => LOG_LEVEL_OPTIONS.find((item) => item.value === level)?.label || level.toUpperCase(),
)
const applyDatePreset = (days: number) => {
setSelectedDateRange(resolvePresetRange(days))
}
const renderLevelTag = (props: CustomTagProps) => {
const { label, value, closable, onClose } = props
return (
<Tag
color={getLogLevelTagColor(String(value))}
closable={closable}
onClose={onClose}
style={{ marginInlineEnd: 4 }}
>
{label}
</Tag>
)
}
return (
<AppLayout>
{contextHolder}
<div className="page-shell logs-page">
<div className="page-shell__header">
<div className="logs-page__header-copy">
<Title level={3} style={{ marginBottom: 2 }}></Title>
<Paragraph type="secondary" className="logs-page__header-desc" style={{ marginBottom: 0 }}>
Planet Earth
</Paragraph>
</div>
</div>
<div className="page-shell__body">
<Card className="logs-page__card">
<div className="logs-page__card-body">
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
<div className="logs-page__toolbar">
<div className="logs-page__toolbar-row logs-page__toolbar-row--primary">
<Select
value={selectedSource}
onChange={(value) => setSelectedSource(value)}
loading={sourcesLoading}
className="logs-page__source-select"
options={sources.map((item) => ({
value: item.source_id,
label: item.name,
}))}
/>
<Select
mode="multiple"
value={selectedLevels}
onChange={(value) => setSelectedLevels(normalizeSelectedLevels(value))}
options={LOG_LEVEL_OPTIONS}
className="logs-page__level-select"
maxTagCount="responsive"
allowClear
tagRender={renderLevelTag}
placeholder="全部级别"
/>
<Input.Search
allowClear
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onSearch={(value) => setSearchQuery(value)}
placeholder="搜索日志内容、模块名、错误关键字"
className="logs-page__search-input"
/>
<button
type="button"
className={`logs-page__filter-toggle ${filtersExpanded ? 'is-expanded' : ''}`}
onClick={() => setFiltersExpanded((value) => !value)}
aria-expanded={filtersExpanded}
aria-label={filtersExpanded ? '收起筛选' : '展开筛选'}
title={hasAdvancedFilters ? '筛选已启用' : '更多筛选'}
>
{filtersExpanded ? <UpOutlined /> : <DownOutlined />}
</button>
</div>
{filtersExpanded ? (
<div className="logs-page__filters-panel">
<div className="logs-page__toolbar-row logs-page__toolbar-row--secondary">
<Select
value={lineLimit}
onChange={(value) => setLineLimit(Number(value))}
options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value}` }))}
className="logs-page__line-limit-select"
popupRender={(menu) => (
<>
{menu}
<div className="logs-page__line-limit-customizer">
<Text type="secondary"></Text>
<InputNumber
min={1}
max={1000}
value={lineLimit}
onChange={(value) => setLineLimit(Number(value) || 200)}
style={{ width: '100%' }}
/>
</div>
</>
)}
/>
<RangePicker
allowClear
value={selectedDateRange}
onChange={(value) => setSelectedDateRange(normalizeDateRange(value as [Dayjs | null, Dayjs | null] | null))}
cellRender={(current, info) => {
if (info.type !== 'date' || !isDayjsValue(current)) return info.originNode
const marker = dailyLogMarkers.get(current.format('YYYY-MM-DD'))
if (!marker) return info.originNode
return (
<div
className={`logs-page__calendar-cell logs-page__calendar-cell--${marker.dominantLevel}`}
title={`${current.format('YYYY-MM-DD')} · ${marker.total} lines`}
>
{info.originNode}
</div>
)
}}
format="YYYY-MM-DD"
placeholder={['开始日期', '结束日期']}
className="logs-page__date-range"
/>
<Space size={6} className="logs-page__preset-group">
{DATE_PRESET_OPTIONS.map((option) => (
<Button
key={option.key}
size="small"
type={activeDatePreset === option.key ? 'primary' : 'default'}
onClick={() => applyDatePreset(option.days)}
>
{option.label}
</Button>
))}
<Button
size="small"
onClick={() => setSelectedDateRange(null)}
disabled={!hasDateFilter}
>
Clear
</Button>
</Space>
</div>
</div>
) : null}
</div>
<div className="logs-page__console-shell">
<div className="system-log-console">
<div className="system-log-console__actions">
<Tooltip
title={
<>
<div><strong>{snapshot?.name || selectedMeta?.name || '未选择日志源'}</strong></div>
<div>: {getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}</div>
<div>: {(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}</div>
<div>: {lineCountLabel} </div>
{selectedLevels.length > 0 ? <div>: {effectiveLevelLabels.join(' / ')}</div> : null}
{selectedDateRange?.[0] && selectedDateRange?.[1]
? <div>: {selectedDateRange[0].format('YYYY-MM-DD')} ~ {selectedDateRange[1].format('YYYY-MM-DD')}</div>
: null}
{searchQuery.trim() ? <div>: {searchQuery.trim()}</div> : null}
<div>{snapshot?.description || selectedMeta?.description || '-'}</div>
<div>: {snapshot?.location || selectedMeta?.location || '-'}</div>
{statusHelp ? <div>{statusHelp}</div> : null}
</>
}
>
<Button
type="text"
size="small"
shape="circle"
icon={<InfoCircleOutlined />}
className="playground-message__actions-btn"
/>
</Tooltip>
<Tooltip title="刷新日志">
<Button
type="text"
size="small"
shape="circle"
icon={<ReloadOutlined />}
className="playground-message__actions-btn"
onClick={() => {
void fetchSources()
if (selectedSource) {
void fetchSnapshot(selectedSource, lineLimit, selectedLevels, selectedDateRange, searchQuery)
}
}}
/>
</Tooltip>
<Tooltip title="复制日志">
<Button
type="text"
size="small"
shape="circle"
icon={<CopyOutlined />}
className="playground-message__actions-btn"
onClick={async () => {
const content = currentResultLines.join('\n')
try {
await navigator.clipboard.writeText(content)
messageApi.success('日志内容已复制')
} catch {
messageApi.error('复制失败,请检查浏览器剪贴板权限')
}
}}
disabled={currentResultLines.length === 0}
/>
</Tooltip>
</div>
{logLoading ? (
<div className="system-log-console__placeholder">
<Spin />
</div>
) : currentResultLines.length > 0 ? (
<Scrollbar className="system-log-console__scroll">
<pre className="system-log-console__content">{currentResultLines.join('\n')}</pre>
</Scrollbar>
) : (
<div className="system-log-console__placeholder">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
selectedDateRange?.[0] && selectedDateRange?.[1]
? '当前日期范围没有匹配的日志内容'
: '当前没有可显示的日志内容'
}
/>
</div>
)}
</div>
</div>
</div>
</Card>
</div>
</div>
</AppLayout>
)
}
export default Logs

View File

@@ -1025,7 +1025,14 @@ start_postgres_service() {
# Backend lifecycle helpers
cleanup_backend_processes() {
pkill -f "uvicorn" 2>/dev/null || true
local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
terminate_backend_processes TERM "$backend_port"
if ! wait_for_port_release "$backend_port"; then
terminate_backend_processes KILL "$backend_port"
wait_for_port_release "$backend_port" || true
fi
}
start_backend_with_retry() {
@@ -1033,8 +1040,9 @@ start_backend_with_retry() {
local retry=1
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
cleanup_backend_processes
cleanup_backend_processes "$backend_port"
cd "$SCRIPT_DIR/backend"
: > /tmp/planet_backend.log
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
BACKEND_PID=$!
@@ -1242,6 +1250,39 @@ collect_port_pids() {
return 1
}
terminate_process_group() {
local signal="$1"
local pid="$2"
local pgid=""
[ -n "$pid" ] || return 0
kill -0 "$pid" 2>/dev/null || return 0
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
[ -n "$pgid" ] || return 0
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
}
terminate_backend_processes() {
local signal="$1"
local backend_port="$2"
local pids=""
local pid=""
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done
pids="$(collect_port_pids "$backend_port" || true)"
for pid in $pids; do
terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid"
done
}
terminate_process_tree() {
local signal="$1"
local pid="$2"
@@ -1521,8 +1562,8 @@ stop_container_if_running() {
}
stop_backend_service() {
if pgrep -f "uvicorn" >/dev/null 2>&1; then
cleanup_backend_processes
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
log_halt "后端服务已停止"
fi
}

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.37.0"
version = "0.40.1"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.37.0"
version = "0.40.1"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },