From 8b8f7138c03084edf637d119af57f362053eade9 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Fri, 24 Apr 2026 00:48:33 +0800 Subject: [PATCH] release: bump version to 0.39.0 --- VERSION | 2 +- backend/app/api/v1/visualization.py | 16 +- backend/app/api/v1/websocket.py | 26 +- backend/app/core/cache.py | 12 +- backend/app/core/logging.py | 161 +++ backend/app/db/session.py | 21 +- backend/app/main.py | 4 + backend/app/services/persistent_logs.py | 22 +- backend/app/services/scheduler.py | 116 +- backend/app/services/system_logs.py | 36 +- backend/tests/test_logging.py | 78 ++ backend/tests/test_system_logs.py | 53 + docs/CHANGELOG.md | 18 + docs/plans/enterprise-logging-system-plan.md | 1234 ++++++++++-------- docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/hud.css | 26 + frontend/public/earth/js/info-card.js | 8 + frontend/src/index.css | 198 ++- frontend/src/pages/Logs/Logs.tsx | 212 +-- pyproject.toml | 2 +- uv.lock | 2 +- 22 files changed, 1490 insertions(+), 762 deletions(-) create mode 100644 backend/app/core/logging.py create mode 100644 backend/tests/test_logging.py diff --git a/VERSION b/VERSION index ca75280b..4ef2eb08 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.38.0 +0.39.0 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index be5fe15f..66515a42 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -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", diff --git a/backend/app/api/v1/websocket.py b/backend/app/api/v1/websocket.py index 23ccb3fe..cde409f0 100644 --- a/backend/app/api/v1/websocket.py +++ b/backend/app/api/v1/websocket.py @@ -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 diff --git a/backend/app/core/cache.py b/backend/app/core/cache.py index d3c250a4..b4885022 100644 --- a/backend/app/core/cache.py +++ b/backend/app/core/cache.py @@ -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( diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 00000000..82adf89e --- /dev/null +++ b/backend/app/core/logging.py @@ -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 diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 8475f295..ae30a1e3 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index 3e2e83b6..ae5ac8cf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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": diff --git a/backend/app/services/persistent_logs.py b/backend/app/services/persistent_logs.py index d0f93a99..838c9f7c 100644 --- a/backend/app/services/persistent_logs.py +++ b/backend/app/services/persistent_logs.py @@ -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}, + ) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index a16d84ff..2f6d187b 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -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 diff --git a/backend/app/services/system_logs.py b/backend/app/services/system_logs.py index 8e404d92..136cf373 100644 --- a/backend/app/services/system_logs.py +++ b/backend/app/services/system_logs.py @@ -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]: diff --git a/backend/tests/test_logging.py b/backend/tests/test_logging.py new file mode 100644 index 00000000..53f790ff --- /dev/null +++ b/backend/tests/test_logging.py @@ -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 diff --git a/backend/tests/test_system_logs.py b/backend/tests/test_system_logs.py index eb1b5efa..f2b8d127 100644 --- a/backend/tests/test_system_logs.py +++ b/backend/tests/test_system_logs.py @@ -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", + ] diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2fb726cf..26e96815 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,24 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.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 diff --git a/docs/plans/enterprise-logging-system-plan.md b/docs/plans/enterprise-logging-system-plan.md index 35a91394..4b7e667a 100644 --- a/docs/plans/enterprise-logging-system-plan.md +++ b/docs/plans/enterprise-logging-system-plan.md @@ -1,665 +1,793 @@ -# Planet 企业级日志系统规范与落地计划 +# Planet 企业级日志系统实施计划 -## 目标 +## Goal -为 Planet 建立一套可持续演进的日志体系,覆盖: +把 Planet 当前“能看一点运行输出”的日志能力,升级为一套真正可用、可定位、可纠错、可追责、可演进的企业级日志系统。 -1. 后端运行日志 -2. 前端浏览器端错误与关键业务日志 -3. 超级管理员操作审计 -4. 高价值事件持久化 -5. 实时排障与历史追溯并存 +这里的“企业级”不是指一上来就接入很重的外部平台,而是指这套系统需要同时满足下面五件事: -最终目标不是“把所有输出都收进一个页面”,而是建立: +1. 排障可用 +2. 历史可查 +3. 业务可解释 +4. 权限操作可追责 +5. 出错后能够反向定位到请求、任务、模块和操作者 -- 统一日志字段规范 -- 统一事件命名规范 -- 统一采集与查询链路 -- 清晰的实时日志层与持久化事件层分工 +最终目标不是“把更多 stdout 放到日志页里”,而是建立一套统一的日志契约与落地链路: -## 当前现状 +- 统一日志字段 +- 统一事件命名 +- 统一采集入口 +- 统一查询视图 +- 清晰的实时日志、持久化事件、审计日志分层 -项目当前已经具备一部分基础: +## Why -- 后端日志可从 `/tmp/planet_backend.log` 查看 -- 前端开发服务日志可从 `/tmp/planet_frontend.log` 查看 -- AI Provider 日志可从 Docker 容器读取 -- Earth 浏览器端错误可通过 API 上报并进入 Redis 缓冲 -- 管理台已有“系统日志”页面,可做来源、级别、日期等筛选 +当前仓库已经有一些日志基础,但离真正可用的日志系统还有明显距离。 -### 当前落地进度 +已有基础: -截至 2026-04-23,第一阶段已经落地的能力有: +- 后端运行日志可通过 `/tmp/planet_backend.log` 查看 +- 前端开发服务日志可通过 `/tmp/planet_frontend.log` 查看 +- AI Provider 可从 Docker 容器读取日志 +- Earth 浏览器端关键日志可上报到后端并进入 Redis 缓冲 +- 已有 `system_logs` / `audit_logs` 持久化能力 +- 管理台已有“系统日志”页面,支持来源、级别、日期、搜索 -- 后端新增 `request_id` 中间件,响应会回传 `X-Request-ID` -- 新增 `system_logs` / `audit_logs` 数据表模型并接入初始化流程 -- Earth 浏览器端上报日志已同时写入缓冲层和 `system_logs` -- `landing-points` / `cables` 关键后端异常已写入 `system_logs` -- 超级管理员触发重启任务时会写入 `audit_logs` +当前缺口: -当前仍未完成的部分: +- 后端日志仍以 `uvicorn` / 文本输出为主,不是统一结构化事件流 +- 不同模块的日志格式不一致,很多地方只有 message,没有 event 语义 +- 还没有统一的后端 logger 封装与字段注入机制 +- 前端虽然能上报错误,但还没有统一 logger API 和统一事件词汇 +- Earth 与管理台之间的错误事件还没有形成可串联的事件链路 +- 历史持久化还偏点状,很多高价值失败并没有系统性落库 +- 系统日志页当前更像“运行输出查看器”,不是“多层日志查询台” +- 审计日志与运行日志尚未形成明确的产品级联动 -- 后端统一结构化 logger 封装还没有全仓替换 -- 前端统一 logger API 还没有扩展到整个控制台 -- 日志页还没有提供“历史事件库”查询视图 -- 采集器与调度器的关键日志还没有系统性入库 +所以当前真正的问题不是“有没有日志页”,而是: -当前主要问题: +**当前系统能看见输出,但还不能稳定回答“发生了什么、影响了谁、在哪条链路上坏了、是否已修复、是谁触发的”。** -- 日志不是统一规范打点,很多地方仍然是临时性输出 -- 后端没有统一 request/trace 相关字段 -- 前端没有统一 logger API,Earth 端虽然已能上报,但仍偏点状能力 -- 当前系统日志页以聚合查看为主,还不是企业级日志架构 -- 日志历史追溯能力不足,尤其 Earth 客户端和关键业务失败事件 -- 审计日志与运行日志还没有严格分层 +## Current State -## 设计原则 +截至 2026-04-23,当前代码中的日志相关能力大致如下。 -### 1. 分层而不是混存 +### 1. 日志来源 -日志分为三层: +当前系统日志页主要读取以下来源: + +- `backend` + 读取 `/tmp/planet_backend.log` +- `frontend` + 读取 `/tmp/planet_frontend.log` +- `ai-provider` + 读取 Docker 容器日志 +- `earth-client` + 读取 Redis 缓冲的浏览器端日志 + +这些来源定义在: + +- [backend/app/services/system_logs.py](/home/ray/dev/linkong/planet/backend/app/services/system_logs.py) + +### 2. 当前日志读取模型 + +当前 `read_log_snapshot()` 的职责是: + +- 读取某个来源的最近若干行 +- 解析基础级别与时间 +- 按级别、日期、搜索进行过滤 +- 返回用于日志页展示的快照 + +这个模型适合“运维查看器”,但不适合企业级日志系统,原因是: + +- 读取基于文本尾部扫描,不是基于事件模型 +- 不同来源的结构粒度完全不同 +- 过滤依赖文本解析,准确率有限 +- 没有请求、任务、用户、资源、动作等核心关联字段 + +### 3. 已有持久化能力 + +当前已经存在两个持久化入口: + +- `record_system_log(...)` +- `record_audit_log(...)` + +位置: + +- [backend/app/services/persistent_logs.py](/home/ray/dev/linkong/planet/backend/app/services/persistent_logs.py) + +这说明系统并不是从 0 开始,但也说明当前最大的问题是: + +**持久化能力存在,但没有成为统一默认路径。** + +### 4. 已有 request_id 基础 + +当前系统已具备 `request_id` 相关基础,部分持久化能力也会尝试写入 `request_id`。 + +这为后续做: + +- 请求链路排障 +- 前后端关联查询 +- 任务执行追踪 + +提供了很好的基础。 + +### 5. 当前日志页定位 + +当前日志页已经具备: + +- 来源切换 +- 级别筛选 +- 日期筛选 +- 搜索 +- 文本控制台视图 + +但它仍然是“单层视图”: + +- 上面是筛选器 +- 下面是一块文本控制台 + +它还不是: + +- 运行日志 + 事件日志 + 审计日志 的统一入口 +- 也没有事件详情、关联跳转、纠错建议、链路追踪能力 + +## Core Principles + +这套日志系统后续必须遵循下面几个原则。 + +### 1. 分层,而不是混存 + +日志必须拆成三层: 1. 运行日志 -- 面向排障、运维、链路观察 -- 默认不直接写业务数据库 -- 主要走 stdout / 文件 / 容器 / 日志平台 - -2. 高价值事件日志 -- 面向历史追溯和业务排查 -- 只持久化 error、warning 和关键业务失败 -- 允许写数据库 - +2. 持久化事件日志 3. 审计日志 -- 面向管理行为留痕 -- 单独建模 -- 不和普通运行日志混用 + +它们的用途不同,绝不能继续混成一个概念。 + +#### 运行日志 + +用于: + +- 实时排障 +- 观察服务运行状态 +- 看 stdout / stderr / exception / collector 输出 + +特点: + +- 数据量大 +- 时效性强 +- 保留周期短 +- 不要求每条都落库 + +#### 持久化事件日志 + +用于: + +- 记录高价值错误 +- 记录关键业务失败 +- 支撑历史追溯 +- 支撑趋势分析 + +特点: + +- 只持久化有价值事件 +- 必须结构化 +- 必须有统一 event 命名 + +#### 审计日志 + +用于: + +- 留痕 +- 追责 +- 还原高权限操作 + +特点: + +- 必须单独建模 +- 不与普通运行日志混用 ### 2. 结构化优先 -所有正式日志都应能拆成字段,而不是只有一句字符串。 +正式日志必须可拆字段,不能长期依赖自由文本。 -### 3. 平台采集优先于业务数据库 +最低要求至少能拿到: -全量日志走日志平台。 +- `timestamp` +- `level` +- `service` +- `module` +- `event` +- `message` +- `request_id` +- `trace_id` +- `user_id` / `actor` +- `context` -数据库只存: +### 3. 事件命名优先于 message 命名 -- 高价值错误事件 -- 关键业务失败事件 -- 审计事件 - -### 4. 前后端统一事件语言 - -同一个问题在前端和后端应尽量共享事件命名。 +人看的 message 可以变化,但机器查询和跨模块关联必须依赖稳定事件名。 例如: -- `earth.landing_points.load_failed` -- `earth.news.feed.refresh_failed` +- `collector.run.started` +- `collector.run.completed` +- `collector.run.failed` +- `earth.layer.load_failed` +- `earth.cruise.route_build_failed` - `system.restart_task.failed` +- `auth.websocket.invalid_token` -这样在页面、API、数据库、日志平台里都能串联查询。 +### 4. 查询链路必须可串联 + +企业级日志系统的核心不是“有很多日志”,而是“能串起来”。 + +最终一条高价值事件,至少要能回链到下面任意几类对象: + +- 某个请求 +- 某个任务 +- 某个用户 +- 某个数据源 +- 某个 Earth 模块 +- 某个管理动作 ### 5. 默认脱敏 -日志禁止记录: +日志体系必须明确禁止记录: - token - password -- cookie - Authorization header -- 完整敏感 PII +- cookie +- session +- 明文敏感个人信息 -## 日志分层规范 +并且需要有统一脱敏器,而不是靠调用者自觉。 -## 一、后端运行日志规范 +### 6. “可纠错”不是一句口号 -### 使用方式 +这里的“可纠错”至少包含三层: -- 统一使用 Python `logging` -- 禁止在正式路径中使用裸 `print` -- 统一 `logger = logging.getLogger(__name__)` +1. 日志字段足够解释错误,方便人排查 +2. 系统能识别常见错误模式并给出纠偏建议 +3. 关键错误支持闭环动作,例如重试、重建索引、重新触发采集、跳转到对应对象 -### 最低字段要求 +也就是说,这套日志系统最终不只是“告诉你出错了”,而要尽量接近“告诉你为什么出错、怎么修、去哪修”。 -后端正式日志至少应能携带: +## Non-Goals -- `timestamp` -- `level` -- `service` -- `module` -- `event` -- `message` -- `request_id` -- `trace_id` -- `user_id` 或 `actor` -- `context` +第一阶段不追求: -### 等级定义 +- 全量接入 ELK / Loki / Datadog / OpenTelemetry 全家桶 +- 做分布式 trace 全链路可视化大屏 +- 把所有历史日志都迁进数据库 +- 先做特别复杂的规则引擎 -- `DEBUG` - 仅开发或短期诊断使用 -- `INFO` - 关键流程开始、结束、状态切换 -- `WARNING` - 可恢复异常、降级、重试、部分失败 -- `ERROR` - 当前请求、任务或操作失败 -- `CRITICAL` - 系统级不可用、核心能力中断 +第一阶段追求的是: -### 推荐记录点 +- 在当前仓库和当前部署方式下,先把基础日志体系做正确 +- 再为后续平台化接入预留好接口 -必须补日志的位置: +## Target Architecture -- API 入口请求摘要 -- API 异常出口 -- 定时任务启动/完成/失败 -- 数据采集器启动/完成/失败 -- 外部依赖失败 -- 关键 Earth 业务 API 失败 +推荐目标架构如下。 -推荐模式: +### Layer 1: Runtime Logs -```python -logger.info( - "collector started", - extra={ - "event": "collector.run.started", - "source": source_name, - "task_id": task_id, - }, -) +职责: + +- 承载后端、前端开发服务、容器输出、浏览器端缓冲事件 +- 提供最近窗口内的实时查看能力 + +来源: + +- 文件 +- Docker +- Redis 缓冲 +- 后续可扩展到 stdout collector + +接口: + +- `GET /api/v1/system/logs/sources` +- `GET /api/v1/system/logs/{source_id}` + +这层继续保留,但需要做结构化增强和来源补强。 + +### Layer 2: Persistent System Events + +职责: + +- 只存高价值事件 +- 供历史追溯、事件列表、趋势和纠错使用 + +数据来源: + +- 后端关键异常 +- 浏览器端关键失败 +- 采集器/调度器关键失败 +- 业务关键告警与降级事件 + +接口建议: + +- `GET /api/v1/system/events` +- `GET /api/v1/system/events/{id}` +- `POST /api/v1/system/events/{id}/actions/...`(后续) + +### Layer 3: Audit Logs + +职责: + +- 留痕高权限操作 +- 记录操作者、对象、结果、请求号 + +接口建议: + +- `GET /api/v1/system/audit-logs` + +### Layer 4: Error Intelligence / Triage + +职责: + +- 对高频错误做归类 +- 对已知错误给出解释与建议动作 +- 对相同错误进行 fingerprint 聚合 + +这是“可纠错”能力的关键层。 + +建议字段: + +- `fingerprint` +- `root_cause_type` +- `known_fix_hint` +- `runbook_url` +- `related_resource_type` +- `related_resource_id` + +## Canonical Event Model + +推荐统一事件字段模型如下。 + +### Runtime Log Record + +```json +{ + "timestamp": "2026-04-23T10:15:30Z", + "level": "error", + "service": "backend", + "module": "app.services.scheduler", + "event": "collector.run.failed", + "message": "Collector bgp_news failed", + "request_id": "req_xxx", + "trace_id": "trace_xxx", + "user_id": null, + "actor": null, + "resource_type": "collector", + "resource_id": "bgp_news", + "context": { + "datasource_id": 12, + "exception_type": "TimeoutError" + } +} ``` -异常必须优先使用: +### Persistent System Event -```python -logger.exception("landing points build failed", extra={"event": "earth.landing_points.load_failed"}) +```json +{ + "id": 1024, + "event": "earth.layer.load_failed", + "level": "error", + "source": "earth-client", + "service": "earth", + "module": "cables", + "message": "Failed to load cable layer", + "fingerprint": "earth.layer.load_failed:cables:network_timeout", + "request_id": "req_xxx", + "trace_id": null, + "user_id": 1, + "resource_type": "earth_layer", + "resource_id": "cables", + "category": "visualization", + "status": "open", + "context": { + "url": "/api/v1/visualization/geo/cables" + }, + "created_at": "2026-04-23T10:15:30Z" +} ``` -## 二、前端日志规范 +### Audit Log -### 前端日志分级 - -前端不做“全量 console 上报”,而做三层: - -1. 本地调试日志 -- 保留在浏览器 console -- 不上报 - -2. 运行时错误 -- `window.onerror` -- `unhandledrejection` -- React/Earth 模块未捕获异常 -- 上报到后端日志入口 - -3. 关键业务事件 -- 接口加载失败 -- 图层初始化失败 -- 巡航队列构建失败 -- 页面关键模块进入降级状态 - -### 前端 logger API 建议 - -统一设计为: - -```ts -logger.debug(event, message, context?) -logger.info(event, message, context?) -logger.warn(event, message, context?) -logger.error(event, message, context?) +```json +{ + "id": 88, + "action": "system.restart_task.requested", + "actor_id": 1, + "actor_name": "root", + "target_type": "restart_task", + "target_id": "restart_20260423_xxx", + "result": "success", + "request_id": "req_xxx", + "ip": "127.0.0.1", + "details": { + "action": "restart_backend" + }, + "created_at": "2026-04-23T10:15:30Z" +} ``` -最低字段要求: +## Implementation Plan -- `timestamp` -- `level` -- `page` -- `module` -- `event` -- `message` -- `url` -- `user_agent` -- `context` +## Phase 0: Logging Inventory And Naming Freeze -### 前端上报范围建议 +目标: -默认上报: +- 先统一“记录什么”和“怎么命名”,避免后面越做越乱 -- `ERROR` -- `WARNING` -- 关键业务失败 `INFO` +工作项: -默认不上报: +- 盘点当前所有 `logging.getLogger` 使用点 +- 盘点裸 `print` +- 盘点 `record_system_log` / `record_audit_log` 已落点位 +- 建立统一事件命名表 +- 定义 service / module / category / resource 字段枚举 +- 输出日志字段白名单和脱敏规范 -- 调试型 `DEBUG` -- 普通开发 `console.log` +完成标准: -## 三、审计日志规范 +- 有一份稳定的事件命名清单 +- 有一份字段规范清单 +- 后续新增日志不再“临时起名” -审计日志单独设计,不和系统运行日志混合。 +## Phase 1: Backend Structured Logging Foundation -### 适用范围 +目标: -- 系统重启 -- 配置变更 -- 数据源启停与优先级调整 -- 调度策略变更 -- 管理员触发采集任务 -- 高权限操作 +- 把后端从“散落 logging + 文本输出”升级成“统一结构化 logger” -### 最低字段要求 +工作项: -- `timestamp` -- `actor_id` -- `actor_name` -- `action` -- `target_type` -- `target_id` -- `result` -- `ip` -- `request_id` -- `details` +- 新增统一后端 logger helper,例如 `app/core/logging.py` +- 自动注入: + - `service` + - `module` + - `request_id` + - `trace_id` +- 增加统一脱敏 filter +- 把关键模块先切到统一 logger: + - API 层 + - scheduler + - collectors + - websocket + - visualization + - system control +- 约束: + - 正式路径禁止裸 `print` + - 正式异常优先 `logger.exception(..., extra={...})` -## 统一事件命名规范 +完成标准: -建议命名采用: +- 后端关键模块都有稳定 `event` +- request 日志和异常日志能挂上 `request_id` +- 不再依赖只看 `uvicorn` 原生文本输出来定位问题 -`...` +## Phase 2: Persistent Event Layer + +目标: + +- 把“值得长期保留的错误和关键事件”系统性落库 + +工作项: + +- 重新定义 `record_system_log()` 的使用边界 +- 明确哪些事件必须持久化: + - API 关键失败 + - 调度器失败 + - 采集器失败 + - Earth 客户端关键错误 + - 数据源不可用 + - 业务降级与恢复 +- 补齐字段: + - `event` + - `resource_type` + - `resource_id` + - `category` + - `fingerprint` + - `status` +- 增加高频错误去重/聚合策略 + +完成标准: + +- 高价值错误不再只存在于运行日志里 +- 能查询最近一周/一月的关键失败事件 +- 相同错误具备聚合基础 + +## Phase 3: Frontend And Earth Unified Logger + +目标: + +- 把前端从“点状 error 上报”升级成统一前端事件流 + +工作项: + +- 在前端新增统一 logger API +- 统一方法: + - `debug` + - `info` + - `warn` + - `error` +- 统一字段: + - `page` + - `module` + - `event` + - `message` + - `url` + - `user_agent` + - `context` +- Earth 模块优先接入: + - layer load failed + - cruise build failed + - popup render failed + - connector render failed + - websocket dropped +- 管理台优先接入: + - settings save failed + - datasource toggle failed + - restart task submit failed + +完成标准: + +- 前端日志事件名与后端可对齐 +- Earth 和管理台关键失败不再只停留在 console +- 浏览器端关键问题能进入统一系统日志/事件层 + +## Phase 4: Audit Logging Completion + +目标: + +- 把管理员与高权限操作真正做成企业级审计 + +工作项: + +- 扩大审计覆盖面: + - 系统重启 + - 数据源启停 + - 调度规则变更 + - 配置变更 + - 人工触发采集 + - 删除/修改关键配置 +- 增加字段: + - actor + - target + - before / after + - request_id + - IP +- 审计页支持: + - 动作筛选 + - 操作者筛选 + - 时间筛选 + - 目标对象筛选 + +完成标准: + +- 所有高权限操作都能追到人、时间、对象、结果 + +## Phase 5: Log Console To Enterprise Observability UI + +目标: + +- 把当前“系统日志”页升级为真正的多层日志工作台 + +工作项: + +- 将页面拆为三个主视图: + 1. 运行日志 + 2. 关键事件 + 3. 审计日志 +- 运行日志视图: + - 保留大控制台 + - 支持来源、级别、日期、搜索 +- 关键事件视图: + - 列表化展示高价值事件 + - 支持聚合、状态、指纹、对象筛选 +- 审计视图: + - 列表化展示管理员动作 +- 增加详情抽屉: + - 原始 message + - context + - request_id + - related resource + - recommended action + +完成标准: + +- 日志页不再只是“终端文本窗口” +- 运维排障、历史追溯、审计留痕三者分层清晰 + +## Phase 6: Corrective Intelligence + +目标: + +- 让系统从“能看日志”进化到“能辅助修错” + +工作项: + +- 引入错误 fingerprint +- 对已知错误配置: + - 根因类型 + - 修复建议 + - runbook 链接 + - 推荐动作 +- 支持常见纠错动作: + - 重试采集任务 + - 重载配置 + - 跳转到对应模块/资源 + - 打开相关日志过滤视图 +- 高频错误支持聚合与静默窗口 + +完成标准: + +- 已知错误能给出明确建议 +- 运维不需要每次都从零猜 + +## Recommended Module Changes + +### Backend + +建议新增/增强的模块: + +- `backend/app/core/logging.py` + - 统一 logger 封装 + - formatter + - filter + - request/trace 注入 +- `backend/app/services/persistent_logs.py` + - 扩展字段 + - 统一持久化策略 +- `backend/app/services/system_logs.py` + - 逐步从“文本尾部查看器”升级为“运行日志聚合器” +- `backend/app/services/log_classification.py` + - 指纹 + - 根因分类 + - 纠错建议 +- `backend/app/api/v1/system_control.py` + - 补充事件 / 审计 / 日志多视图接口 + +### Frontend + +建议新增/增强: + +- `frontend/src/lib/logger.ts` + - 统一前端 logger API +- `frontend/src/pages/Logs/Logs.tsx` + - 升级为多层工作台 +- `frontend/public/earth/js/...` + - 各 Earth 模块接入统一事件 logger + +## Event Naming Convention + +建议采用: + +`...` 示例: -- `earth.landing_points.load_failed` -- `earth.news.feed.refreshed` -- `earth.news.cruise_queue.built` -- `system.logs.snapshot_requested` -- `system.restart_task.started` -- `system.restart_task.failed` -- `datasource.collector.run_failed` -- `settings.system.updated` +- `collector.datasource.run.started` +- `collector.datasource.run.failed` +- `earth.layer.cables.load.failed` +- `earth.cruise.route.build.failed` +- `system.restart_task.requested` +- `system.restart_task.completed` +- `auth.websocket.connect.failed` +- `settings.datasource.priority.updated` 规则: -- domain 使用稳定业务域 -- module 指实际模块 -- action 使用动词 -- result 使用过去时或结果词 +- 不用自然语言句子 +- 不把 ID 塞进 event 名里 +- 资源对象通过字段承载,不通过 event 名承载 -## 企业级目标架构 +## Query Model -推荐采用“双轨架构”: +最终推荐支持的查询维度: -1. 实时运行日志轨 -2. 高价值持久化事件轨 +- 时间范围 +- level +- source +- service +- module +- event +- request_id +- trace_id +- user_id / actor +- resource_type / resource_id +- category +- fingerprint +- status +- full-text search -```mermaid -flowchart LR - A["Frontend / Earth"] --> B["Frontend Logger"] - C["Backend API / Scheduler / Collectors"] --> D["Backend Logger"] - B --> E["Log Ingest API"] - D --> F["stdout / file / docker logs"] - F --> G["Log Collector"] - G --> H["Log Platform (Loki / ELK / Datadog)"] - E --> I["High-value Event Filter"] - D --> I - I --> J["PostgreSQL system_logs / audit_logs"] - H --> K["Ops Search / Alerting"] - J --> L["Admin Logs UI / History Query"] -``` +## Retention Strategy -### 实时运行日志层 +推荐保留策略: -职责: +- 运行日志: + - 文件 / 容器 / Redis 缓冲保留短周期 +- 持久化事件: + - 保留中长期 +- 审计日志: + - 长期保留 -- 低延迟排障 -- 实时观察 -- 全文检索 -- 告警触发 +初版可以先这样: -推荐落地: +- 运行日志:7 到 14 天 +- 关键事件:90 到 180 天 +- 审计日志:180 天以上 -- 开发期:文件 + Docker + 管理台聚合查看 -- 标准化阶段:Fluent Bit / Vector -> Loki 或 ELK +后续再根据存储与合规要求调整。 -### 高价值事件层 +## Security And Compliance -职责: +必须落实: -- 长期追溯 -- 按业务事件检索 -- 与产品页面、管理台联动 +- 敏感字段脱敏 +- 前端上报白名单 +- 防止日志注入 +- 审计日志不可被普通管理员随意篡改 +- 高敏感纠错动作必须再次鉴权 -推荐落地: +## Success Criteria -- PostgreSQL `system_logs` -- PostgreSQL `audit_logs` +当下面这些条件成立时,才算这套日志系统真的“成了”: -## 数据库设计建议 +1. 一个后端请求失败时,能通过 `request_id` 在运行日志、持久化事件、审计日志之间串联查询 +2. 一个 Earth 前端错误能定位到页面、模块、事件名和上下文 +3. 一个采集器失败能同时看到运行日志、持久化事件和可执行纠错动作 +4. 一个管理员操作能查到操作者、目标对象、结果和 request_id +5. 日志页不再只是文本控制台,而是完整的“运行日志 / 关键事件 / 审计日志”工作台 +6. 高频已知错误能聚合并给出修复建议 -## 一、`system_logs` +## Delivery Order -只存高价值运行事件,不存全量流水。 +推荐严格按下面顺序做,不要乱跳: -建议字段: +1. Phase 0 命名与字段规范冻结 +2. Phase 1 后端结构化 logging 基础 +3. Phase 2 高价值事件持久化 +4. Phase 3 前端 / Earth 统一 logger +5. Phase 4 审计覆盖补齐 +6. Phase 5 日志工作台 UI 重构 +7. Phase 6 指纹 / 纠错 / runbook -- `id` -- `occurred_at` -- `source` -- `service` -- `module` -- `event` -- `level` -- `message` -- `request_id` -- `trace_id` -- `user_id` -- `category` -- `context` -- `retention_class` -- `created_at` +原因: -### 典型 source +- 如果不先统一字段和命名,后面 UI 和持久化会越来越乱 +- 如果不先做后端结构化基础,前端上报再多也串不起来 +- 如果不先补持久化层,就只有“实时可看”,没有“历史可查” -- `backend` -- `frontend` -- `earth-client` -- `scheduler` -- `collector` -- `ai-provider` +## First Actionable Milestone -### 典型 retention_class +如果要从明天就开始做,最合理的第一个里程碑是: -- `short_term` -- `incident` -- `audit_linked` +### M1: 让后端关键路径全部拥有统一结构化事件 -## 二、`audit_logs` +范围: -建议字段: +- API 请求入口/出口 +- scheduler +- collectors +- websocket +- visualization +- system control -- `id` -- `occurred_at` -- `actor_id` -- `actor_name` -- `action` -- `target_type` -- `target_id` -- `result` -- `request_id` -- `ip` -- `details` -- `created_at` +交付物: -## 系统日志页面演进目标 +- 统一 logger helper +- 统一 event naming 表 +- 统一 request_id 注入 +- 统一脱敏策略 +- 关键模块替换完成 -当前日志页已经有基础能力,但企业级目标应拆成两个视图: +完成这个里程碑后,Planet 才算真正拥有了“企业级日志系统的地基”。 -1. 实时日志视图 -- 来源 -- 级别 -- 日期范围 -- 实时刷新 -- 原始日志查看 - -2. 历史事件视图 -- 查询 `system_logs` -- 查询 `audit_logs` -- 支持按事件名、来源、级别、时间范围、用户筛选 - -不建议让同一个视图同时承担: - -- 全量运行日志 -- 审计日志 -- 业务事件历史 - -推荐分 Tab 或分页面。 - -## 分阶段落地计划 - -## 第一阶段:统一规范与最小治理 - -### 目标 - -把当前零散日志行为统一起来,为后续平台化做准备。 - -### 任务 - -1. 后端统一 logger 入口 -- 清理裸 `print` -- 补齐关键异常 `logger.exception` -- 统一关键 event 名称 - -2. 前端统一 logger API -- 为 Earth 和管理台提供统一日志封装 -- 收敛浏览器端错误上报 - -3. 日志字段规范文档落地 -- 在仓库中固定字段、事件命名、级别约定 - -### 验收标准 - -- 后端关键失败路径不再依赖 `print` -- Earth 端关键失败通过统一 API 上报 -- 新代码使用统一 event 命名 - -## 第二阶段:上下文打通 - -### 目标 - -让前后端日志可串联。 - -### 任务 - -1. 后端增加 `request_id` -- 中间件生成并注入 -- 响应头回传 - -2. 前端请求链带上 `request_id` -- 或至少在错误展示中保留后端返回 request id - -3. 关键接口补 `trace` 相关上下文 - -### 验收标准 - -- 单个失败请求可以从前端提示一路查到后端日志 -- 系统日志页可展示 request id 或关联字段 - -## 第三阶段:高价值事件入库 - -### 目标 - -建立真正的历史追溯能力。 - -### 任务 - -1. 新增 `system_logs` 表 -2. 新增 `audit_logs` 表 -3. 持久化以下内容: -- Earth 客户端错误 -- 后端 `ERROR/WARNING` -- 关键业务失败事件 -- 超级管理员操作审计 - -4. 管理台增加历史事件查询 - -### 验收标准 - -- 服务重启后仍能查到关键错误 -- Earth 侧错误不依赖 Redis TTL 才能追踪 -- 管理员关键操作有审计记录 - -## 第四阶段:日志平台接入 - -### 目标 - -把全量运行日志从“页面聚合查看”升级为标准日志平台。 - -### 推荐技术路线 - -可选方案 A: - -- Fluent Bit -- Loki -- Grafana - -可选方案 B: - -- Filebeat -- Elasticsearch -- Kibana - -### 任务 - -1. 统一 stdout / file / docker 输出接入 collector -2. 接入集中日志平台 -3. 配置基础检索与告警规则 - -### 验收标准 - -- 可按 service / level / event / request_id 检索 -- 可做错误率和高频事件趋势观察 -- 可配置告警 - -## 第五阶段:日志治理与成本控制 - -### 目标 - -控制噪音、成本和维护复杂度。 - -### 任务 - -1. 明确保留策略 -- 实时日志平台保留周期 -- `system_logs` 保留周期 -- `audit_logs` 保留周期 - -2. 限制 DEBUG/INFO 噪音 -3. 脱敏检查 -4. 高价值事件分级 - -### 验收标准 - -- 数据量可控 -- 日志可用性提升而不是噪音堆积 -- 无敏感信息泄露 - -## 开发任务拆分 - -## A. 后端 - -### A1. 日志中间件 - -- 新增 request id middleware -- 注入 logger context -- 响应头透出 request id - -### A2. logger 封装 - -- 提供统一 helper -- 统一 event 与 context 传法 - -### A3. 高价值日志落库 - -- 新增 model / migration -- 新增写入 service -- 对关键异常和 Earth ingest 进行入库 - -### A4. 审计日志 - -- 对 system control、settings、datasource 管理接口补 audit - -## B. 前端 - -### B1. logger SDK - -- `logger.error/warn/info/debug` -- 自动补 page、module、url - -### B2. Earth 接入 - -- 图层加载 -- 新闻模块 -- 巡航模块 -- 关键交互失败 - -### B3. 管理台接入 - -- 系统控制页面 -- 设置页 -- 数据源管理页 - -### B4. 日志页面 - -- 分离实时视图与历史视图 -- 补 request id / event / source / level 查询 - -## 里程碑建议 - -### M1. 规范收口 - -- 输出统一日志规范 -- 清理核心裸输出 - -### M2. 请求链串联 - -- request id 打通 - -### M3. 关键事件落库 - -- `system_logs` + `audit_logs` - -### M4. 平台化 - -- Loki/ELK 接入 - -## 风险与取舍 - -### 风险 1:直接全量入库 - -不建议。 - -问题: - -- 数据膨胀快 -- 检索体验差 -- 业务库压力增加 - -### 风险 2:只做实时日志不做高价值持久化 - -不够。 - -问题: - -- 故障后无法追溯 -- 前端错误容易因 TTL / 重启丢失 - -### 风险 3:没有事件命名治理 - -问题: - -- 页面能看日志,但无法做稳定聚合与检索 - -## 推荐实施顺序 - -最推荐的实际推进顺序: - -1. 统一后端/前端 logger 规范 -2. 打通 request id -3. 新增 `system_logs` 与 `audit_logs` -4. 只持久化高价值事件 -5. 最后接日志平台 - -这是对当前 Planet 成本最低、收益最高、也最接近企业级实践的路线。 - -## 本计划的最终验收 - -当以下条件满足时,可认为日志系统初步达到企业级可用水平: - -- 后端关键失败路径都有结构化日志 -- Earth 前端关键失败可统一上报 -- 管理员关键操作可审计 -- 高价值错误可长期追溯 -- 实时日志与历史事件分层明确 -- 至少有 request_id 或等价链路串联能力 -- 日志页不再只是“看文件”,而是具备查询真实事件的能力 diff --git a/docs/version-history.md b/docs/version-history.md index 05dc5b4c..23c66c72 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -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 | diff --git a/frontend/package.json b/frontend/package.json index febe4770..14c29d70 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.38.0", + "version": "0.39.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/hud.css b/frontend/public/earth/css/hud.css index 6190665d..685c2dcd 100644 --- a/frontend/public/earth/css/hud.css +++ b/frontend/public/earth/css/hud.css @@ -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, diff --git a/frontend/public/earth/js/info-card.js b/frontend/public/earth/js/info-card.js index 08ed03f7..f6433d8b 100644 --- a/frontend/public/earth/js/info-card.js +++ b/frontend/public/earth/js/info-card.js @@ -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) diff --git a/frontend/src/index.css b/frontend/src/index.css index 72a39976..69919df7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); + } } diff --git a/frontend/src/pages/Logs/Logs.tsx b/frontend/src/pages/Logs/Logs.tsx index 46911e42..9da9291c 100644 --- a/frontend/src/pages/Logs/Logs.tsx +++ b/frontend/src/pages/Logs/Logs.tsx @@ -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(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() {
- 系统日志 - + 系统日志 + 统一查看 Planet 当前关键服务日志,并串联 Earth 浏览器端错误、后端异常与服务输出。
@@ -344,27 +345,6 @@ function Logs() { tagRender={renderLevelTag} placeholder="全部级别" /> - setLineLimit(Number(value))} + options={LOG_LIMIT_OPTIONS.map((value) => ({ value, label: `最近 ${value} 行` }))} + className="logs-page__line-limit-select" + popupRender={(menu) => ( + <> + {menu} +
+ 自定义行数 + setLineLimit(Number(value) || 200)} + style={{ width: '100%' }} + /> +
+ + )} + /> + 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 ( -
+ {info.originNode} +
+ ) + }} + format="YYYY-MM-DD" + placeholder={['开始日期', '结束日期']} + className="logs-page__date-range" + /> + + {DATE_PRESET_OPTIONS.map((option) => ( + + ))} +
- ) - }} - format="YYYY-MM-DD" - placeholder={['开始日期', '结束日期']} - className="logs-page__date-range" - /> - - {DATE_PRESET_OPTIONS.map((option) => ( - - ))} - - -
+ Clear + + + + + ) : null} -
-
- - {snapshot?.name || selectedMeta?.name || '未选择日志源'} - - {getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')} - - {(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()} - 当前显示 {lineCountLabel} 行 - -
- - {snapshot?.description || selectedMeta?.description || '-'} - - 位置: {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()}` : ''} - - {statusHelp ? : null} - -
- -
+
+
+ +
{snapshot?.name || selectedMeta?.name || '未选择日志源'}
+
状态: {getStatusLabel(snapshot?.status || selectedMeta?.status || 'default')}
+
类型: {(snapshot?.kind || selectedMeta?.kind || 'unknown').toUpperCase()}
+
当前显示: {lineCountLabel} 行
+ {selectedLevels.length > 0 ?
级别: {effectiveLevelLabels.join(' / ')}
: null} + {selectedDateRange?.[0] && selectedDateRange?.[1] + ?
日期: {selectedDateRange[0].format('YYYY-MM-DD')} ~ {selectedDateRange[1].format('YYYY-MM-DD')}
+ : null} + {searchQuery.trim() ?
检索: {searchQuery.trim()}
: null} +
{snapshot?.description || selectedMeta?.description || '-'}
+
位置: {snapshot?.location || selectedMeta?.location || '-'}
+ {statusHelp ?
{statusHelp}
: null} + + } + > +
)} +
diff --git a/pyproject.toml b/pyproject.toml index 792adc6c..a5702e77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planet" -version = "0.38.0" +version = "0.39.0" description = "智能星球计划 - 态势感知系统" requires-python = ">=3.14" dependencies = [ diff --git a/uv.lock b/uv.lock index 78350968..18d04871 100644 --- a/uv.lock +++ b/uv.lock @@ -475,7 +475,7 @@ wheels = [ [[package]] name = "planet" -version = "0.38.0" +version = "0.39.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" },