release: bump version to 0.39.0

This commit is contained in:
rayd1o
2026-04-24 00:48:33 +08:00
parent d5f3784ffb
commit 8b8f7138c0
22 changed files with 1490 additions and 762 deletions

View File

@@ -1 +1 @@
0.38.0
0.39.0

View File

@@ -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"
)
@@ -993,7 +993,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 +1044,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",

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

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

View File

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

View File

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

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

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

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

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

View File

@@ -8,6 +8,24 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [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

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.38.0`
- `dev` 当前开发分支历史推导到:`0.39.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `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 |

View File

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

View File

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

View File

@@ -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;
@@ -836,6 +841,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)

View File

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

View File

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

View File

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

2
uv.lock generated
View File

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