Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9464a9833 | ||
|
|
86807f6af6 | ||
|
|
8b8f7138c0 |
@@ -5,7 +5,6 @@ Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import logging
|
||||
import math
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query, Response
|
||||
@@ -25,9 +24,10 @@ 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 = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__, service="api")
|
||||
TERRAIN_TILE_URL_TEMPLATE = (
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
||||
)
|
||||
@@ -184,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",
|
||||
@@ -193,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"),
|
||||
@@ -213,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)
|
||||
@@ -993,7 +1026,11 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to build cables GeoJSON response")
|
||||
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",
|
||||
@@ -1040,7 +1077,11 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to build landing points GeoJSON response")
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
161
backend/app/core/logging.py
Normal 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
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -6,8 +5,9 @@ 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 = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DB_POOL_CONFIG = {
|
||||
"pool_pre_ping": True,
|
||||
@@ -111,13 +111,16 @@ async def init_db():
|
||||
import app.models.playground_message # noqa: F401
|
||||
import app.models.system_log # noqa: F401
|
||||
|
||||
logger.warning(
|
||||
"Database pool settings active: pre_ping=%s recycle=%ss size=%s overflow=%s timeout=%ss",
|
||||
DB_POOL_CONFIG["pool_pre_ping"],
|
||||
DB_POOL_CONFIG["pool_recycle"],
|
||||
DB_POOL_CONFIG["pool_size"],
|
||||
DB_POOL_CONFIG["max_overflow"],
|
||||
DB_POOL_CONFIG["pool_timeout"],
|
||||
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:
|
||||
|
||||
@@ -8,6 +8,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.api.main import api_router
|
||||
from app.api.v1 import websocket
|
||||
from app.core.config import settings
|
||||
from app.core.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
|
||||
@@ -19,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":
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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 = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def record_system_log(
|
||||
@@ -33,17 +33,21 @@ async def record_system_log(
|
||||
module=module,
|
||||
event=event,
|
||||
level=level.lower(),
|
||||
message=message,
|
||||
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=context or {},
|
||||
context=sanitize_log_value(context or {}),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to persist system log event=%s source=%s", event, source)
|
||||
logger.exception_event(
|
||||
"Failed to persist system log",
|
||||
event="system_log.persist.failed",
|
||||
context={"event_name": event, "source": source},
|
||||
)
|
||||
|
||||
|
||||
async def record_audit_log(
|
||||
@@ -70,9 +74,13 @@ async def record_audit_log(
|
||||
result=result,
|
||||
request_id=request_id or get_request_id(),
|
||||
ip=ip,
|
||||
details=details or {},
|
||||
details=sanitize_log_value(details or {}),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
logger.exception("Failed to persist audit log action=%s", action)
|
||||
logger.exception_event(
|
||||
"Failed to persist audit log",
|
||||
event="audit_log.persist.failed",
|
||||
context={"action": action},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ 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)
|
||||
@@ -260,19 +261,19 @@ def parse_prefixed_timestamp(line: str) -> tuple[datetime | None, str]:
|
||||
return None, stripped
|
||||
|
||||
|
||||
def infer_log_level_from_text(text: str) -> str | None:
|
||||
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))
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -288,10 +289,15 @@ def build_display_line(timestamp: datetime | None, level: str | None, message: s
|
||||
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:
|
||||
timestamp, remainder = parse_prefixed_timestamp(line)
|
||||
level = infer_log_level_from_text(remainder or line)
|
||||
display_line = line.rstrip("\n")
|
||||
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,
|
||||
@@ -338,7 +344,11 @@ def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogE
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
recent_lines = deque(handle, maxlen=scan_limit)
|
||||
return [parse_text_log_entry(line) for line in recent_lines if line.strip()]
|
||||
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]:
|
||||
|
||||
78
backend/tests/test_logging.py
Normal file
78
backend/tests/test_logging.py
Normal 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
|
||||
@@ -162,3 +162,56 @@ def test_infer_log_level_prefers_leading_prefix_over_query_string():
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -8,6 +8,53 @@ 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
|
||||
- 前端上下文
|
||||
- Earth 前端结构
|
||||
- Earth 卫星 footprint 策略
|
||||
- 后端运行控制
|
||||
- collector 现状
|
||||
- 采集格式约定
|
||||
|
||||
198
docs/technical/earth-satellite-footprint-policy.md
Normal file
198
docs/technical/earth-satellite-footprint-policy.md
Normal 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
|
||||
@@ -16,12 +16,15 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.38.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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.38.0",
|
||||
"version": "0.40.1",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -634,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;
|
||||
}
|
||||
@@ -1024,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,
|
||||
|
||||
@@ -687,6 +687,19 @@
|
||||
</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>
|
||||
<label class="earth-mobile-settings-card">
|
||||
@@ -875,6 +888,30 @@
|
||||
</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">
|
||||
|
||||
@@ -25,6 +25,14 @@ export const CRUISE_MODULES = {
|
||||
|
||||
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,
|
||||
|
||||
79
frontend/public/earth/js/controls.js
vendored
79
frontend/public/earth/js/controls.js
vendored
@@ -4,9 +4,11 @@ import * as THREE from "three";
|
||||
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 {
|
||||
@@ -34,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";
|
||||
@@ -116,6 +120,9 @@ 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;
|
||||
@@ -641,6 +648,7 @@ function getCurrentSharedSettingsSnapshot() {
|
||||
return {
|
||||
rotationMode,
|
||||
cruiseModules: getCruiseModules(),
|
||||
satelliteDisplayStyle: getSatelliteDisplayStyle(),
|
||||
layerVisibility: Object.fromEntries(
|
||||
getPersistedLayers().map((layer) => [layer.id, Boolean(layer.getVisible?.())]),
|
||||
),
|
||||
@@ -676,6 +684,8 @@ function cloneEarthSettings(settings) {
|
||||
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,
|
||||
@@ -755,6 +765,11 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
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
|
||||
@@ -770,6 +785,7 @@ function normalizeEarthSettings(rawSettings, defaults) {
|
||||
cruiseModules: nextCruiseModules.length > 0
|
||||
? nextCruiseModules
|
||||
: [...DEFAULT_CRUISE_MODULES],
|
||||
satelliteDisplayStyle: nextSatelliteDisplayStyle,
|
||||
layerVisibility: normalizedLayerVisibility,
|
||||
terrainOpacity: Number.isFinite(nextTerrainOpacity)
|
||||
? nextTerrainOpacity
|
||||
@@ -883,6 +899,17 @@ function syncCruiseModuleControls() {
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -925,6 +952,42 @@ export function setCruiseModules(nextModules, { persist = true, suppressStatus =
|
||||
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]");
|
||||
@@ -988,6 +1051,10 @@ 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 });
|
||||
@@ -1797,6 +1864,7 @@ function setupSettingsControls() {
|
||||
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) => {
|
||||
@@ -1869,6 +1937,16 @@ function setupSettingsControls() {
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -1886,6 +1964,7 @@ function setupSettingsControls() {
|
||||
syncAllHudPanelToggles();
|
||||
syncRotationModeButtons();
|
||||
syncCruiseModuleControls();
|
||||
syncSatelliteDisplayStyleControls();
|
||||
syncDayNightToggle(dayNightEnabled);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,11 @@ function getMobileDetailRenderKey(type, data) {
|
||||
].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;
|
||||
@@ -432,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' },
|
||||
@@ -836,6 +845,9 @@ export function showInfoCard(type, data, options = {}) {
|
||||
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)
|
||||
|
||||
167
frontend/public/earth/js/iridium-footprint-adapter.js
Normal file
167
frontend/public/earth/js/iridium-footprint-adapter.js
Normal 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);
|
||||
}
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
getSatelliteCount,
|
||||
selectSatellite,
|
||||
getSatellitePoints,
|
||||
getSatellitePresentationInfo,
|
||||
setSatelliteRingState,
|
||||
updateLockedRingPosition,
|
||||
updateHoverRingPosition,
|
||||
@@ -93,7 +94,9 @@ import {
|
||||
updateBreathingPhase,
|
||||
isSatelliteFrontFacing,
|
||||
setSatelliteCamera,
|
||||
setSatelliteSunDirection,
|
||||
setLockedSatelliteIndex,
|
||||
setHoveredSatelliteIndex,
|
||||
resetSatelliteState,
|
||||
clearSatelliteData,
|
||||
} from "./satellites.js";
|
||||
@@ -480,6 +483,7 @@ function clearTransientHoverState() {
|
||||
}
|
||||
hoveredSatellite = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
setHoveredSatelliteIndex(null);
|
||||
}
|
||||
|
||||
function applyBGPHoverState(marker) {
|
||||
@@ -576,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());
|
||||
@@ -583,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,
|
||||
@@ -919,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");
|
||||
@@ -1050,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 },
|
||||
});
|
||||
@@ -2647,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) {
|
||||
@@ -2946,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;
|
||||
@@ -3013,6 +3022,9 @@ function onClick(event) {
|
||||
"locked",
|
||||
satPositions[selectedIndex].current,
|
||||
);
|
||||
if (hoveredSatelliteIndex === selectedIndex) {
|
||||
setHoveredSatelliteIndex(selectedIndex);
|
||||
}
|
||||
}
|
||||
|
||||
showSatelliteInfo(sat.properties, { x: event.clientX, y: event.clientY });
|
||||
@@ -3115,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 (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3417,7 +3417,7 @@ body {
|
||||
.system-log-console {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-height: 480px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
background: #020617;
|
||||
@@ -3448,13 +3448,18 @@ body {
|
||||
.system-log-console__scroll,
|
||||
.system-log-console__scroll .scrollbar__viewport {
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.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 88px 18px 20px;
|
||||
min-height: 480px;
|
||||
padding: 18px 124px 18px 20px;
|
||||
color: #e2e8f0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
@@ -3464,7 +3469,6 @@ body {
|
||||
}
|
||||
|
||||
.system-log-console__placeholder {
|
||||
min-height: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -3472,14 +3476,24 @@ body {
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -3492,6 +3506,7 @@ body {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.logs-page__card-body {
|
||||
@@ -3499,36 +3514,27 @@ body {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-page__summary-card {
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fafafa 100%);
|
||||
border: 1px solid rgba(5, 5, 5, 0.06);
|
||||
}
|
||||
|
||||
.logs-page__summary-header {
|
||||
.logs-page__console-shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-page__toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.98) 0%, rgba(245, 247, 250, 0.98) 100%);
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
|
||||
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 {
|
||||
@@ -3540,7 +3546,9 @@ body {
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--primary {
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
@@ -3549,12 +3557,13 @@ body {
|
||||
}
|
||||
|
||||
.logs-page__search-input {
|
||||
width: min(560px, 100%);
|
||||
min-width: 220px;
|
||||
width: 100%;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.logs-page__level-select {
|
||||
width: 220px;
|
||||
width: 100%;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.logs-page__date-range {
|
||||
@@ -3565,15 +3574,62 @@ body {
|
||||
width: 156px;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--search .logs-page__search-input {
|
||||
flex: 1 1 auto;
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -3620,6 +3676,74 @@ body {
|
||||
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;
|
||||
@@ -3630,8 +3754,14 @@ body {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.logs-page__toolbar-row--primary {
|
||||
flex-wrap: wrap;
|
||||
.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,
|
||||
@@ -3641,4 +3771,8 @@ body {
|
||||
.logs-page__line-limit-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-page__console-shell {
|
||||
min-height: clamp(280px, 50vh, 620px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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, ReloadOutlined } from '@ant-design/icons'
|
||||
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'
|
||||
@@ -104,12 +104,6 @@ function readStoredFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTagColor(status: string): string {
|
||||
if (status === 'ok') return 'success'
|
||||
if (status === 'missing') return 'warning'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
if (status === 'ok') return '可用'
|
||||
if (status === 'missing') return '暂无日志'
|
||||
@@ -173,6 +167,12 @@ function Logs() {
|
||||
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 () => {
|
||||
@@ -279,6 +279,7 @@ function Logs() {
|
||||
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(
|
||||
@@ -309,8 +310,8 @@ function Logs() {
|
||||
<div className="page-shell logs-page">
|
||||
<div className="page-shell__header">
|
||||
<div className="logs-page__header-copy">
|
||||
<Title level={3} style={{ marginBottom: 4 }}>系统日志</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
<Title level={3} style={{ marginBottom: 2 }}>系统日志</Title>
|
||||
<Paragraph type="secondary" className="logs-page__header-desc" style={{ marginBottom: 0 }}>
|
||||
统一查看 Planet 当前关键服务日志,并串联 Earth 浏览器端错误、后端异常与服务输出。
|
||||
</Paragraph>
|
||||
</div>
|
||||
@@ -344,27 +345,6 @@ function Logs() {
|
||||
tagRender={renderLevelTag}
|
||||
placeholder="全部级别"
|
||||
/>
|
||||
<Select
|
||||
value={lineLimit}
|
||||
onChange={(value) => setLineLimit(Number(value))}
|
||||
options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value} 行` }))}
|
||||
className="logs-page__line-limit-select"
|
||||
dropdownRender={(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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={searchQuery}
|
||||
@@ -373,81 +353,118 @@ function Logs() {
|
||||
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>
|
||||
|
||||
<div className="logs-page__toolbar-row logs-page__toolbar-row--secondary">
|
||||
<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
|
||||
{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
|
||||
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`}
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
Clear
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="logs-page__summary-card">
|
||||
<div className="logs-page__summary-header">
|
||||
<Space wrap size={8}>
|
||||
<Text strong>{snapshot?.name || selectedMeta?.name || '未选择日志源'}</Text>
|
||||
<Tag color={getStatusTagColor(snapshot?.status || selectedMeta?.status || 'default')}>
|
||||
{getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}
|
||||
</Tag>
|
||||
<Tag>{(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}</Tag>
|
||||
<Text type="secondary">当前显示 {lineCountLabel} 行</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary">{snapshot?.description || selectedMeta?.description || '-'}</Text>
|
||||
<Text type="secondary">
|
||||
位置: {snapshot?.location || selectedMeta?.location || '-'}
|
||||
{selectedLevels.length > 0 ? ` · 级别: ${effectiveLevelLabels.join(' / ')}` : ''}
|
||||
{selectedDateRange?.[0] && selectedDateRange?.[1]
|
||||
? ` · 日期: ${selectedDateRange[0].format('YYYY-MM-DD')} ~ ${selectedDateRange[1].format('YYYY-MM-DD')}`
|
||||
: ''}
|
||||
{searchQuery.trim() ? ` · 检索: ${searchQuery.trim()}` : ''}
|
||||
</Text>
|
||||
{statusHelp ? <Alert type="info" showIcon message={statusHelp} /> : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="system-log-console">
|
||||
<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"
|
||||
@@ -503,6 +520,7 @@ function Logs() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.38.0"
|
||||
version = "0.40.1"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user