Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18ffa0b0a | ||
| d15a9d488a | |||
|
|
eb4c4b7904 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,8 +28,8 @@ dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
/lib/
|
||||
/lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
15
TODO.md
15
TODO.md
@@ -11,10 +11,8 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
|
||||
- [ ] Import authoritative China POV / coastline / claim-line source packages through the three standard Earth boundary source collectors, then rebuild a versioned PMTiles artifact so highest zoom `8-10` preserves trusted source geometry instead of seed data.
|
||||
- [ ] Earth boundary data: acquire or generate auditable China POV geometry for Zangnan, Aksai Chin, Taiwan/Penghu, Diaoyu Dao and affiliated islands, Chiwei Yu, South China Sea islands, Kosovo, Gaza, and the official dashed maritime claim line before implementing final visual changes.
|
||||
- [ ] Earth high-resolution basemap tiles: implement the viewport-loaded imagery layer described in [Earth High Resolution Basemap Tiles Plan](/home/ray/dev/linkong/planet/docs/plans/earth-high-resolution-basemap-tiles-plan.md), using high-precision coastline as the alignment reference instead of replacing the globe with one huge texture.
|
||||
- [ ] Presentation controller ownership: replace the singleton card fallback in [presentation-controller.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/presentation-controller.js) with a presentation/card token check before BGP/News migrate onto the shared controller, so connectors only attach to their owning card.
|
||||
- [ ] BGP frontend maintainability: split [bgp.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp.js) by responsibility into data loading, marker rendering, overlays, and animation once the current interaction behavior is stable.
|
||||
- [ ] Optional BGP marker experiment: evaluate HTML markers for BGP incident/collector points if WebGL marker density or fixed screen-size clickability becomes a real blocker.
|
||||
- [ ] Earth news cruise: connect Earth news to the generic cruise queue via a news adapter rather than coupling news-specific sequencing into `main.js`.
|
||||
|
||||
## Compute Centers And Location
|
||||
|
||||
@@ -45,13 +43,6 @@ This file is the active backlog only. Completed history belongs in `docs/CHANGEL
|
||||
- [ ] Compatibility schema: cover adapter type, base URL pattern, auth header, thinking/reasoning defaults, stream path, tool-call capability, multimodal capability, and provider-specific request patches.
|
||||
- [ ] BGP geography fallback: evaluate `inetnum` / `inet6num` whois as a finer fallback layer after `prefix_geography`, `OpenGeoFeed`, and RIR delegated data.
|
||||
|
||||
## Platform
|
||||
|
||||
- [ ] Earth preferences scope: keep current device-local Earth preferences in `localStorage`; only design backend user preferences if account-level synchronization becomes a real product requirement.
|
||||
- [ ] System logs: finish a usable Planet log viewing flow that covers backend, frontend, AI Provider, and collector/task logs, with filtering and tailing.
|
||||
- [ ] Console UI modernization: gradually replace Ant Design with Planet-owned components and a consistent Tabler Icons based icon system.
|
||||
- [ ] Earth live sync: design a unified realtime invalidation path for summary/BGP/satellite updates if polling and current WebSocket channels become insufficient.
|
||||
|
||||
## Archive
|
||||
|
||||
Archived items stay here so old context is not lost. Completed items remain checked; obsolete, invalid, or superseded items stay unchecked and include the reason.
|
||||
@@ -75,6 +66,11 @@ Archived items stay here so old context is not lost. Completed items remain chec
|
||||
- [x] Added OpenGeoFeed as a high-quality prefix geography override source.
|
||||
- [x] Made RIR delegated data a prefix geography fallback rather than the primary source.
|
||||
- [x] Added route leak and path instability / flap detectors after the activity layer work.
|
||||
- [x] Console UI modernization. Admin is now the only console, legacy Ant Design / Admin Next code paths and dependencies have been removed, and current console UI uses Planet-owned components.
|
||||
- [x] Earth news cruise adapter. News cruise now uses `news-cruise-adapter.js` and is wired from `main.js` instead of keeping news-specific sequencing directly in the main Earth loop.
|
||||
- [x] Presentation controller ownership. `PresentationController` now guards async ownership through active request identity checks, and current callers pass per-request card targets so stale connector/card work cannot overwrite the active presentation.
|
||||
- [x] Earth live sync. Database writes now flow through `earth_data_change_events`, `earth_db_change_listener`, layer adapters, cache invalidation, and the `earth_updates` WebSocket channel; the Earth frontend debounces updates and refreshes BGP, cables, compute centers, satellites, vessels, news, and interactables by layer.
|
||||
- [x] System logs. Log sources now normalize into `LogEvent`, Admin supports snapshot filtering plus WebSocket tail/follow, task/detail views deep-link into prefiltered logs, and Admin runtime errors report through the `admin-client` log source.
|
||||
|
||||
### Obsolete Or Superseded
|
||||
|
||||
@@ -85,3 +81,4 @@ Archived items stay here so old context is not lost. Completed items remain chec
|
||||
- [ ] Earth surface material overlay for boundary calibration. Superseded by the high-precision boundary tile plan; future work must use source-faithful boundary/coastline data rather than overlay calibration against the coarse base map.
|
||||
- [ ] Hardcoded Earth news source extraction as a standalone task. Superseded by the broader Earth news source configuration and collector plans.
|
||||
- [ ] Country-level compute-center fallback placement as a standalone task. Superseded by the shared location pipeline and registry/manual-review backlog.
|
||||
- [ ] Earth preferences backend sync scope. Superseded by the current product decision to keep Earth preferences device-local in `localStorage` until account-level synchronization becomes a real requirement.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
@@ -9,13 +8,11 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import ROOT_DIR
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.system_log import AuditLog, SystemLog
|
||||
from app.models.user import User
|
||||
from app.services.persistent_logs import record_audit_log, record_system_log
|
||||
from app.services.system_control import (
|
||||
@@ -38,6 +35,7 @@ from app.services.system_logs import (
|
||||
append_buffer_log,
|
||||
list_log_sources,
|
||||
normalize_log_level,
|
||||
read_database_log_snapshot,
|
||||
read_log_snapshot,
|
||||
)
|
||||
from app.services.earth_layer_cache import earth_layer_cache
|
||||
@@ -45,42 +43,6 @@ from app.services.earth_layer_cache import earth_layer_cache
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _compact_log_context(context: dict | None) -> str:
|
||||
if not context:
|
||||
return ""
|
||||
allowed = {
|
||||
key: value
|
||||
for key, value in (context or {}).items()
|
||||
if key
|
||||
in {
|
||||
"status",
|
||||
"duration_ms",
|
||||
"provider",
|
||||
"model",
|
||||
"result_provider",
|
||||
"result_model",
|
||||
"collector_name",
|
||||
"datasource_id",
|
||||
"task_id",
|
||||
"snapshot_id",
|
||||
"raw_count",
|
||||
"transformed_count",
|
||||
"saved_count",
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"deleted",
|
||||
"result_count",
|
||||
"status_code",
|
||||
"error_type",
|
||||
"error",
|
||||
}
|
||||
}
|
||||
if not allowed:
|
||||
return ""
|
||||
return json.dumps(allowed, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
class RestartTaskCreate(BaseModel):
|
||||
action: str
|
||||
|
||||
@@ -154,6 +116,46 @@ class EarthClientLogEventResponse(BaseModel):
|
||||
level: str
|
||||
|
||||
|
||||
async def ingest_client_log_event(
|
||||
source_id: str,
|
||||
*,
|
||||
service: str,
|
||||
event: str,
|
||||
default_module: str,
|
||||
default_category: str,
|
||||
payload: EarthClientLogEventCreate,
|
||||
request: Request,
|
||||
) -> EarthClientLogEventResponse:
|
||||
normalized_level = normalize_log_level(payload.level)
|
||||
append_buffer_log(
|
||||
source_id,
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
context={
|
||||
"category": payload.category or "",
|
||||
"url": payload.url or "",
|
||||
"module": payload.module or "",
|
||||
"detail": payload.detail or "",
|
||||
},
|
||||
)
|
||||
await record_system_log(
|
||||
source=source_id,
|
||||
service=service,
|
||||
module=payload.module or default_module,
|
||||
event=event,
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
category=payload.category or default_category,
|
||||
context={
|
||||
"url": payload.url or "",
|
||||
"detail": payload.detail or "",
|
||||
"module": payload.module or "",
|
||||
"client_ip": request.client.host if request.client else "",
|
||||
},
|
||||
)
|
||||
return EarthClientLogEventResponse(accepted=True, source_id=source_id, level=normalized_level)
|
||||
|
||||
|
||||
class EarthLayerCacheStatusResponse(BaseModel):
|
||||
prefix: str
|
||||
key_count: int
|
||||
@@ -376,100 +378,6 @@ async def get_system_log_sources(
|
||||
}
|
||||
|
||||
|
||||
async def read_database_log_snapshot(
|
||||
source_id: str,
|
||||
*,
|
||||
limit: int,
|
||||
level: str,
|
||||
levels: str | None,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
search: str | None,
|
||||
db: AsyncSession,
|
||||
) -> dict | None:
|
||||
selected_levels = set(normalize_log_level(item) for item in (levels or level).split(",") if item.strip())
|
||||
selected_levels.discard("all")
|
||||
search_query = (search or "").strip().lower()
|
||||
lines: list[str] = []
|
||||
|
||||
if source_id == "system-db":
|
||||
query = select(SystemLog).order_by(SystemLog.occurred_at.desc().nullslast(), SystemLog.id.desc()).limit(limit * 5)
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
for record in records:
|
||||
record_level = normalize_log_level(record.level)
|
||||
if selected_levels and record_level not in selected_levels:
|
||||
continue
|
||||
occurred_at = record.occurred_at.date().isoformat() if record.occurred_at else ""
|
||||
if start_date and occurred_at and occurred_at < start_date:
|
||||
continue
|
||||
if end_date and occurred_at and occurred_at > end_date:
|
||||
continue
|
||||
line = " ".join(
|
||||
part
|
||||
for part in [
|
||||
record.occurred_at.isoformat() if record.occurred_at else "",
|
||||
record_level.upper(),
|
||||
record.source,
|
||||
record.category or "",
|
||||
record.event or "",
|
||||
f"request_id={record.request_id}" if record.request_id else "",
|
||||
record.message,
|
||||
_compact_log_context(record.context),
|
||||
]
|
||||
if part
|
||||
)
|
||||
if search_query and search_query not in line.lower():
|
||||
continue
|
||||
lines.append(line)
|
||||
elif source_id == "audit-db":
|
||||
query = select(AuditLog).order_by(AuditLog.occurred_at.desc().nullslast(), AuditLog.id.desc()).limit(limit * 5)
|
||||
result = await db.execute(query)
|
||||
records = result.scalars().all()
|
||||
for record in records:
|
||||
occurred_at = record.occurred_at.date().isoformat() if record.occurred_at else ""
|
||||
if start_date and occurred_at and occurred_at < start_date:
|
||||
continue
|
||||
if end_date and occurred_at and occurred_at > end_date:
|
||||
continue
|
||||
line = " ".join(
|
||||
part
|
||||
for part in [
|
||||
record.occurred_at.isoformat() if record.occurred_at else "",
|
||||
"INFO",
|
||||
record.action,
|
||||
record.target_type or "",
|
||||
record.target_id or "",
|
||||
record.result or "",
|
||||
]
|
||||
if part
|
||||
)
|
||||
if search_query and search_query not in line.lower():
|
||||
continue
|
||||
lines.append(line)
|
||||
else:
|
||||
return None
|
||||
|
||||
lines = list(reversed(lines[:limit]))
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"name": "系统事件" if source_id == "system-db" else "审计事件",
|
||||
"kind": "database",
|
||||
"location": "table://system_logs" if source_id == "system-db" else "table://audit_logs",
|
||||
"description": "数据库持久化日志",
|
||||
"category": "database" if source_id == "system-db" else "audit",
|
||||
"status": "ok" if lines else "empty",
|
||||
"level": level,
|
||||
"selected_levels": sorted(selected_levels),
|
||||
"search_query": search or "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": [],
|
||||
"line_limit": limit,
|
||||
"line_count": len(lines),
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs/{source_id}", response_model=SystemLogSnapshotResponse)
|
||||
async def get_system_log_snapshot(
|
||||
source_id: str,
|
||||
@@ -533,31 +441,28 @@ async def ingest_earth_client_log(
|
||||
payload: EarthClientLogEventCreate,
|
||||
request: Request,
|
||||
):
|
||||
normalized_level = normalize_log_level(payload.level)
|
||||
append_buffer_log(
|
||||
return await ingest_client_log_event(
|
||||
"earth-client",
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
context={
|
||||
"category": payload.category or "",
|
||||
"url": payload.url or "",
|
||||
"module": payload.module or "",
|
||||
"detail": payload.detail or "",
|
||||
},
|
||||
)
|
||||
await record_system_log(
|
||||
source="earth-client",
|
||||
service="earth",
|
||||
module=payload.module or "earth-client",
|
||||
event="earth.client.runtime_log",
|
||||
level=normalized_level,
|
||||
message=payload.message,
|
||||
category=payload.category or "client-runtime",
|
||||
context={
|
||||
"url": payload.url or "",
|
||||
"detail": payload.detail or "",
|
||||
"module": payload.module or "",
|
||||
"client_ip": request.client.host if request.client else "",
|
||||
},
|
||||
default_module="earth-client",
|
||||
default_category="client-runtime",
|
||||
payload=payload,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logs/admin-client", response_model=EarthClientLogEventResponse)
|
||||
async def ingest_admin_client_log(
|
||||
payload: EarthClientLogEventCreate,
|
||||
request: Request,
|
||||
):
|
||||
return await ingest_client_log_event(
|
||||
"admin-client",
|
||||
service="admin",
|
||||
event="admin.client.runtime_log",
|
||||
default_module="admin-client",
|
||||
default_category="client-runtime",
|
||||
payload=payload,
|
||||
request=request,
|
||||
)
|
||||
return {"accepted": True, "source_id": "earth-client", "level": normalized_level}
|
||||
|
||||
@@ -6,11 +6,14 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
|
||||
from jose import jwt, JWTError
|
||||
from sqlalchemy import text
|
||||
|
||||
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
|
||||
from app.db.session import async_session_factory
|
||||
from app.services.log_tail import LOG_TAIL_CHANNEL, log_tail_manager
|
||||
|
||||
logger = get_logger(__name__, service="api")
|
||||
router = APIRouter()
|
||||
@@ -37,6 +40,28 @@ async def authenticate_token(token: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
async def load_websocket_user_role(user_id: str | None) -> str | None:
|
||||
if not user_id:
|
||||
return None
|
||||
try:
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
text("SELECT role, is_active FROM users WHERE id = :id"),
|
||||
{"id": int(user_id)},
|
||||
)
|
||||
row = result.fetchone()
|
||||
except Exception as exc:
|
||||
logger.warning_event(
|
||||
"WebSocket user role lookup failed",
|
||||
event="auth.websocket.role_lookup_failed",
|
||||
context={"user_id": user_id, "error": str(exc)},
|
||||
)
|
||||
return None
|
||||
if row is None or not row[1]:
|
||||
return None
|
||||
return str(row[0] or "")
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
@@ -59,6 +84,7 @@ async def websocket_endpoint(
|
||||
|
||||
is_anonymous = payload is None
|
||||
user_id = str(payload.get("sub")) if payload else f"anonymous:{id(websocket)}"
|
||||
user_role = await load_websocket_user_role(user_id) if payload else None
|
||||
supported_channels = ["vessels", "earth_news", EARTH_UPDATES_CHANNEL] if is_anonymous else [
|
||||
"gpu_clusters",
|
||||
"submarine_cables",
|
||||
@@ -70,6 +96,8 @@ async def websocket_endpoint(
|
||||
"earth_news",
|
||||
EARTH_UPDATES_CHANNEL,
|
||||
]
|
||||
if user_role == "super_admin":
|
||||
supported_channels = [*supported_channels, LOG_TAIL_CHANNEL]
|
||||
await manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
@@ -100,6 +128,7 @@ async def websocket_endpoint(
|
||||
payload_data = data.get("data", {})
|
||||
if not isinstance(payload_data, dict):
|
||||
payload_data = {}
|
||||
log_tail_config = None
|
||||
channels = payload_data.get("channels", [])
|
||||
if isinstance(channels, str):
|
||||
channels = [channels]
|
||||
@@ -108,6 +137,26 @@ async def websocket_endpoint(
|
||||
channel = payload_data.get("channel")
|
||||
if channel and channel not in channels:
|
||||
channels = [*channels, channel]
|
||||
if LOG_TAIL_CHANNEL in channels:
|
||||
if user_role != "super_admin":
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_error",
|
||||
"data": {"channel": LOG_TAIL_CHANNEL, "detail": "Only super_admin can subscribe logs"},
|
||||
}
|
||||
)
|
||||
channels = [item for item in channels if item != LOG_TAIL_CHANNEL]
|
||||
else:
|
||||
try:
|
||||
log_tail_config = await log_tail_manager.subscribe(websocket, payload_data)
|
||||
except ValueError as exc:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "subscription_error",
|
||||
"data": {"channel": LOG_TAIL_CHANNEL, "detail": str(exc)},
|
||||
}
|
||||
)
|
||||
channels = [item for item in channels if item != LOG_TAIL_CHANNEL]
|
||||
if is_anonymous:
|
||||
channels = [channel for channel in channels if channel in supported_channels]
|
||||
vessel_subscription = None
|
||||
@@ -131,14 +180,20 @@ async def websocket_endpoint(
|
||||
"action": "subscribe",
|
||||
"channels": [
|
||||
*channels,
|
||||
*([LOG_TAIL_CHANNEL] if log_tail_config else []),
|
||||
*(["vessels"] if vessel_subscription else []),
|
||||
],
|
||||
"vessels": vessel_subscription,
|
||||
"logs_tail": log_tail_config.__dict__ if log_tail_config else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "unsubscribe":
|
||||
channels = data.get("data", {}).get("channels", [])
|
||||
if isinstance(channels, str):
|
||||
channels = [channels]
|
||||
if LOG_TAIL_CHANNEL in channels:
|
||||
await log_tail_manager.unsubscribe(websocket)
|
||||
manager.unsubscribe(websocket, channels)
|
||||
await websocket.send_json(
|
||||
{
|
||||
@@ -159,4 +214,5 @@ async def websocket_endpoint(
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
await log_tail_manager.disconnect(websocket)
|
||||
manager.disconnect(websocket, user_id)
|
||||
|
||||
@@ -32,18 +32,18 @@ class DocsMetadata:
|
||||
|
||||
DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata(DOCS_README_FILENAME, DEFAULT_DOCS_SLUG, "public", "Overview", 0, "技术文档", "Technical Docs"),
|
||||
DocsMetadata("manual.md", "manual", "public", "Manual", 1, "Planet 使用手册", "Planet Manual"),
|
||||
DocsMetadata("manual.md", "manual", "public", "Manual", 1, "智能星球使用手册", "Intelligent Planet Manual"),
|
||||
DocsMetadata("quickstart.md", "quickstart", "public", "Manual", 2, "快速开始", "Quickstart"),
|
||||
DocsMetadata("faq.md", "faq", "public", "Manual", 3, "常见问题", "FAQ"),
|
||||
DocsMetadata("platform-data-flows.md", "platform-data-flows", "docs_developer", "Architecture", 5, "业务架构与数据流转", "Business Architecture and Data Flows"),
|
||||
DocsMetadata("naming-glossary.md", "naming-glossary", "docs_developer", "Architecture", 6, "命名与术语对照", "Naming Glossary"),
|
||||
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "Earth 前端结构", "Earth Frontend Context"),
|
||||
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "Earth 图层样式属性索引", "Earth Layer Style Reference"),
|
||||
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "Earth 渲染图层顺序", "Earth Render Layer Order"),
|
||||
DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "Earth 卫星覆盖策略", "Earth Satellite Footprint Policy"),
|
||||
DocsMetadata("earth-frontend-context.md", "earth-frontend-context", "docs_developer", "Earth", 10, "智能星球前端结构", "Intelligent Planet Frontend Context"),
|
||||
DocsMetadata("earth-layer-style-reference.md", "earth-layer-style-reference", "docs_developer", "Earth", 11, "智能星球图层样式属性索引", "Intelligent Planet Layer Style Reference"),
|
||||
DocsMetadata("earth-render-layer-order.md", "earth-render-layer-order", "docs_developer", "Earth", 12, "智能星球渲染图层顺序", "Intelligent Planet Render Layer Order"),
|
||||
DocsMetadata("earth-satellite-footprint-policy.md", "earth-satellite-footprint-policy", "docs_developer", "Earth", 13, "智能星球卫星覆盖策略", "Intelligent Planet Satellite Footprint Policy"),
|
||||
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "Earth 可交互图标接入", "Earth Interactable Usage"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "Earth 工具栏与浮层协同", "Earth Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "智能星球可交互图标接入", "Intelligent Planet Interactable Usage"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "智能星球工具栏与浮层协同", "Intelligent Planet Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||
DocsMetadata("tactile-ui-components.md", "tactile-ui-components", "docs_developer", "Frontend", 24, "Tactile UI 组件库", "Tactile UI Components"),
|
||||
@@ -56,7 +56,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Backend", 36, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Backend", 37, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||
DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "Planet 运维手册", "Planet Ops Runbook"),
|
||||
DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "智能星球运维手册", "Intelligent Planet Ops Runbook"),
|
||||
DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"),
|
||||
DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"),
|
||||
)
|
||||
|
||||
161
backend/app/services/log_tail.py
Normal file
161
backend/app/services/log_tail.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.db.session import async_session_factory
|
||||
from app.services.system_logs import (
|
||||
DEFAULT_LOG_LINE_LIMIT,
|
||||
LOG_SOURCES,
|
||||
MAX_LOG_LINE_LIMIT,
|
||||
read_database_log_events,
|
||||
read_log_events,
|
||||
)
|
||||
|
||||
DATABASE_LOG_SOURCE_IDS = {"system-db", "audit-db"}
|
||||
LOG_TAIL_CHANNEL = "logs_tail"
|
||||
LOG_TAIL_INTERVAL_SECONDS = 1.5
|
||||
LOG_TAIL_SCAN_MULTIPLIER = 5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogTailConfig:
|
||||
source_id: str
|
||||
limit: int = DEFAULT_LOG_LINE_LIMIT
|
||||
level: str = "all"
|
||||
levels: str | None = None
|
||||
start_date: str | None = None
|
||||
end_date: str | None = None
|
||||
search: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogTailSubscription:
|
||||
config: LogTailConfig
|
||||
emitted_cursors: set[str] = field(default_factory=set)
|
||||
task: asyncio.Task | None = None
|
||||
|
||||
|
||||
class LogTailManager:
|
||||
def __init__(self) -> None:
|
||||
self._subscriptions: dict[WebSocket, LogTailSubscription] = {}
|
||||
|
||||
def normalize_config(self, payload: dict[str, Any]) -> LogTailConfig:
|
||||
source_id = str(payload.get("source_id") or payload.get("source") or "").strip()
|
||||
if not source_id:
|
||||
raise ValueError("source_id is required")
|
||||
if source_id not in LOG_SOURCES and source_id not in DATABASE_LOG_SOURCE_IDS:
|
||||
raise ValueError("Log source not found")
|
||||
try:
|
||||
limit = int(payload.get("limit") or DEFAULT_LOG_LINE_LIMIT)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("limit must be a number") from exc
|
||||
if limit < 1 or limit > MAX_LOG_LINE_LIMIT:
|
||||
raise ValueError(f"limit must be between 1 and {MAX_LOG_LINE_LIMIT}")
|
||||
return LogTailConfig(
|
||||
source_id=source_id,
|
||||
limit=limit,
|
||||
level=str(payload.get("level") or "all"),
|
||||
levels=str(payload.get("levels")).strip() if payload.get("levels") else None,
|
||||
start_date=str(payload.get("start_date")).strip() if payload.get("start_date") else None,
|
||||
end_date=str(payload.get("end_date")).strip() if payload.get("end_date") else None,
|
||||
search=str(payload.get("search")).strip() if payload.get("search") else None,
|
||||
)
|
||||
|
||||
async def subscribe(self, websocket: WebSocket, payload: dict[str, Any]) -> LogTailConfig:
|
||||
config = self.normalize_config(payload)
|
||||
await self.unsubscribe(websocket)
|
||||
subscription = LogTailSubscription(config=config)
|
||||
subscription.task = asyncio.create_task(self._run_tail(websocket, subscription))
|
||||
self._subscriptions[websocket] = subscription
|
||||
return config
|
||||
|
||||
async def unsubscribe(self, websocket: WebSocket) -> None:
|
||||
subscription = self._subscriptions.pop(websocket, None)
|
||||
if subscription and subscription.task:
|
||||
subscription.task.cancel()
|
||||
try:
|
||||
await subscription.task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def disconnect(self, websocket: WebSocket) -> None:
|
||||
await self.unsubscribe(websocket)
|
||||
|
||||
async def _run_tail(self, websocket: WebSocket, subscription: LogTailSubscription) -> None:
|
||||
first_frame = True
|
||||
while True:
|
||||
events = await self._read_events(subscription.config)
|
||||
if first_frame:
|
||||
visible_events = events[-subscription.config.limit :]
|
||||
subscription.emitted_cursors.update(event.cursor for event in visible_events)
|
||||
await self._send_frame(websocket, subscription.config, "snapshot", visible_events)
|
||||
first_frame = False
|
||||
else:
|
||||
new_events = [
|
||||
event
|
||||
for event in events
|
||||
if event.cursor not in subscription.emitted_cursors
|
||||
]
|
||||
if new_events:
|
||||
visible_events = new_events[-subscription.config.limit :]
|
||||
subscription.emitted_cursors.update(event.cursor for event in visible_events)
|
||||
await self._send_frame(websocket, subscription.config, "append", visible_events)
|
||||
await asyncio.sleep(LOG_TAIL_INTERVAL_SECONDS)
|
||||
|
||||
async def _read_events(self, config: LogTailConfig):
|
||||
scan_limit = max(config.limit * LOG_TAIL_SCAN_MULTIPLIER, config.limit)
|
||||
if config.source_id in DATABASE_LOG_SOURCE_IDS:
|
||||
async with async_session_factory() as db:
|
||||
events = await read_database_log_events(
|
||||
config.source_id,
|
||||
scan_limit=scan_limit,
|
||||
level=config.level,
|
||||
levels=config.levels,
|
||||
start_date=config.start_date,
|
||||
end_date=config.end_date,
|
||||
search=config.search,
|
||||
db=db,
|
||||
)
|
||||
return events or []
|
||||
events = read_log_events(
|
||||
config.source_id,
|
||||
scan_limit=scan_limit,
|
||||
level=config.level,
|
||||
levels=config.levels,
|
||||
start_date=config.start_date,
|
||||
end_date=config.end_date,
|
||||
search=config.search,
|
||||
)
|
||||
return events or []
|
||||
|
||||
async def _send_frame(self, websocket: WebSocket, config: LogTailConfig, mode: str, events) -> None:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "data_frame",
|
||||
"channel": LOG_TAIL_CHANNEL,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"payload": {
|
||||
"mode": mode,
|
||||
"source_id": config.source_id,
|
||||
"line_count": len(events),
|
||||
"lines": [event.line for event in events],
|
||||
"filters": {
|
||||
"limit": config.limit,
|
||||
"level": config.level,
|
||||
"levels": config.levels,
|
||||
"start_date": config.start_date,
|
||||
"end_date": config.end_date,
|
||||
"search": config.search,
|
||||
},
|
||||
"status": "ok",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
log_tail_manager = LogTailManager()
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import hashlib
|
||||
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass
|
||||
@@ -13,6 +14,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.core.security import redis_client
|
||||
from app.models.system_log import AuditLog, SystemLog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
DEFAULT_LOG_LINE_LIMIT = 200
|
||||
MAX_LOG_LINE_LIMIT = 1000
|
||||
@@ -99,6 +103,16 @@ class StructuredLogEntry:
|
||||
search_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LogEvent:
|
||||
source_id: str
|
||||
cursor: str
|
||||
timestamp: datetime | None
|
||||
level: str | None
|
||||
line: str
|
||||
search_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyLogMarker:
|
||||
date_token: str
|
||||
@@ -135,7 +149,7 @@ LOG_SOURCES: dict[str, LogSource] = {
|
||||
name="前端开发服务",
|
||||
kind="file",
|
||||
location=_state_log_path("frontend.log"),
|
||||
description="控制台与 Earth 前端开发服务输出。",
|
||||
description="控制台与智能星球前端开发服务输出。",
|
||||
category="service",
|
||||
fallback_locations=("/tmp/planet_frontend.log",),
|
||||
),
|
||||
@@ -150,13 +164,22 @@ LOG_SOURCES: dict[str, LogSource] = {
|
||||
),
|
||||
"earth-client": LogSource(
|
||||
source_id="earth-client",
|
||||
name="Earth 浏览器端",
|
||||
name="智能星球浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:earth-client",
|
||||
description="Earth 浏览器端上报的运行时错误与关键业务日志。",
|
||||
description="智能星球浏览器端上报的运行时错误与关键业务日志。",
|
||||
category="client",
|
||||
buffer_key=f"{LOG_BUFFER_KEY_PREFIX}:earth-client",
|
||||
),
|
||||
"admin-client": LogSource(
|
||||
source_id="admin-client",
|
||||
name="控制台浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:admin-client",
|
||||
description="控制台浏览器端上报的运行时错误。",
|
||||
category="client",
|
||||
buffer_key=f"{LOG_BUFFER_KEY_PREFIX}:admin-client",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +388,44 @@ def build_buffer_entry(payload: dict[str, Any]) -> StructuredLogEntry:
|
||||
)
|
||||
|
||||
|
||||
def compact_log_context(context: dict | None) -> str:
|
||||
if not context:
|
||||
return ""
|
||||
allowed = {
|
||||
key: value
|
||||
for key, value in (context or {}).items()
|
||||
if key
|
||||
in {
|
||||
"status",
|
||||
"duration_ms",
|
||||
"provider",
|
||||
"model",
|
||||
"result_provider",
|
||||
"result_model",
|
||||
"collector_name",
|
||||
"datasource_id",
|
||||
"task_id",
|
||||
"snapshot_id",
|
||||
"raw_count",
|
||||
"transformed_count",
|
||||
"saved_count",
|
||||
"created",
|
||||
"updated",
|
||||
"unchanged",
|
||||
"deleted",
|
||||
"result_count",
|
||||
"status_code",
|
||||
"error_type",
|
||||
"error",
|
||||
"route",
|
||||
"module",
|
||||
}
|
||||
}
|
||||
if not allowed:
|
||||
return ""
|
||||
return json.dumps(allowed, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def read_file_entries(source: LogSource, scan_limit: int) -> list[StructuredLogEntry]:
|
||||
path = resolve_file_log_path(source)
|
||||
if not path.exists():
|
||||
@@ -437,6 +498,176 @@ def read_source_entries(source: LogSource, scan_limit: int) -> list[StructuredLo
|
||||
return []
|
||||
|
||||
|
||||
def _database_event_from_system_record(record: SystemLog) -> LogEvent:
|
||||
record_level = normalize_log_level(record.level)
|
||||
line = " ".join(
|
||||
part
|
||||
for part in [
|
||||
record.occurred_at.isoformat() if record.occurred_at else "",
|
||||
record_level.upper(),
|
||||
record.source,
|
||||
record.category or "",
|
||||
record.event or "",
|
||||
f"request_id={record.request_id}" if record.request_id else "",
|
||||
record.message,
|
||||
compact_log_context(record.context),
|
||||
]
|
||||
if part
|
||||
)
|
||||
search_text = " ".join(
|
||||
[
|
||||
line,
|
||||
f"id={record.id}",
|
||||
f"user_id={record.user_id}" if record.user_id else "",
|
||||
json.dumps(record.context or {}, ensure_ascii=False, sort_keys=True),
|
||||
]
|
||||
).lower()
|
||||
return LogEvent(
|
||||
source_id="system-db",
|
||||
cursor=f"system-db:{record.id}",
|
||||
timestamp=record.occurred_at,
|
||||
level=None if record_level == LOG_LEVEL_ALL else record_level,
|
||||
line=line,
|
||||
search_text=search_text,
|
||||
)
|
||||
|
||||
|
||||
def _database_event_from_audit_record(record: AuditLog) -> LogEvent:
|
||||
line = " ".join(
|
||||
part
|
||||
for part in [
|
||||
record.occurred_at.isoformat() if record.occurred_at else "",
|
||||
"INFO",
|
||||
record.action,
|
||||
record.target_type or "",
|
||||
record.target_id or "",
|
||||
record.result or "",
|
||||
f"request_id={record.request_id}" if record.request_id else "",
|
||||
]
|
||||
if part
|
||||
)
|
||||
search_text = " ".join(
|
||||
[
|
||||
line,
|
||||
f"id={record.id}",
|
||||
f"actor_id={record.actor_id}" if record.actor_id else "",
|
||||
record.actor_name or "",
|
||||
json.dumps(record.details or {}, ensure_ascii=False, sort_keys=True),
|
||||
]
|
||||
).lower()
|
||||
return LogEvent(
|
||||
source_id="audit-db",
|
||||
cursor=f"audit-db:{record.id}",
|
||||
timestamp=record.occurred_at,
|
||||
level=LOG_LEVEL_INFO,
|
||||
line=line,
|
||||
search_text=search_text,
|
||||
)
|
||||
|
||||
|
||||
async def read_database_log_events(
|
||||
source_id: str,
|
||||
*,
|
||||
scan_limit: int,
|
||||
level: str = LOG_LEVEL_ALL,
|
||||
levels: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
search: str | None = None,
|
||||
db: AsyncSession,
|
||||
) -> list[LogEvent] | None:
|
||||
selected_levels = normalize_log_levels(level, levels)
|
||||
search_query = (search or "").strip()
|
||||
if source_id == "system-db":
|
||||
query = select(SystemLog).order_by(SystemLog.occurred_at.desc().nullslast(), SystemLog.id.desc()).limit(scan_limit)
|
||||
result = await db.execute(query)
|
||||
events = [_database_event_from_system_record(record) for record in result.scalars().all()]
|
||||
elif source_id == "audit-db":
|
||||
query = select(AuditLog).order_by(AuditLog.occurred_at.desc().nullslast(), AuditLog.id.desc()).limit(scan_limit)
|
||||
result = await db.execute(query)
|
||||
events = [_database_event_from_audit_record(record) for record in result.scalars().all()]
|
||||
else:
|
||||
return None
|
||||
|
||||
events = list(reversed(events))
|
||||
return [
|
||||
event
|
||||
for event in events
|
||||
if event_matches_levels(event, selected_levels)
|
||||
and event_matches_search(event, search_query)
|
||||
and event_matches_date_range(event, start_date, end_date)
|
||||
]
|
||||
|
||||
|
||||
async def read_database_log_snapshot(
|
||||
source_id: str,
|
||||
*,
|
||||
limit: int,
|
||||
level: str,
|
||||
levels: str | None,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
search: str | None,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any] | None:
|
||||
events = await read_database_log_events(
|
||||
source_id,
|
||||
scan_limit=limit * 5,
|
||||
level=level,
|
||||
levels=levels,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
search=search,
|
||||
db=db,
|
||||
)
|
||||
if events is None:
|
||||
return None
|
||||
visible_events = events[-limit:]
|
||||
selected_levels = normalize_log_levels(level, levels)
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"name": "系统事件" if source_id == "system-db" else "审计事件",
|
||||
"kind": "database",
|
||||
"location": "table://system_logs" if source_id == "system-db" else "table://audit_logs",
|
||||
"description": "数据库持久化日志",
|
||||
"category": "database" if source_id == "system-db" else "audit",
|
||||
"status": "ok" if visible_events else "empty",
|
||||
"level": level,
|
||||
"selected_levels": list(selected_levels),
|
||||
"search_query": search or "",
|
||||
"available_levels": ["all", "error", "warning", "info", "debug"],
|
||||
"daily_markers": build_daily_log_markers_from_events(events),
|
||||
"line_limit": limit,
|
||||
"line_count": len(visible_events),
|
||||
"lines": [event.line for event in visible_events],
|
||||
}
|
||||
|
||||
|
||||
def _stable_hash(value: str) -> str:
|
||||
return hashlib.sha1(value.encode("utf-8", errors="replace")).hexdigest()[:16]
|
||||
|
||||
|
||||
def build_log_events(source_id: str, entries: list[StructuredLogEntry]) -> list[LogEvent]:
|
||||
events: list[LogEvent] = []
|
||||
seen: dict[str, int] = {}
|
||||
for entry in entries:
|
||||
stable_value = entry.raw_line or entry.display_line
|
||||
digest = _stable_hash(stable_value)
|
||||
occurrence = seen.get(digest, 0) + 1
|
||||
seen[digest] = occurrence
|
||||
events.append(
|
||||
LogEvent(
|
||||
source_id=source_id,
|
||||
cursor=f"{source_id}:{digest}:{occurrence}",
|
||||
timestamp=entry.timestamp,
|
||||
level=entry.level,
|
||||
line=entry.display_line,
|
||||
search_text=entry.search_text,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def matches_levels(entry: StructuredLogEntry, selected_levels: tuple[str, ...]) -> bool:
|
||||
if not selected_levels:
|
||||
return True
|
||||
@@ -469,6 +700,34 @@ def matches_search(entry: StructuredLogEntry, search: str | None) -> bool:
|
||||
return query in entry.search_text
|
||||
|
||||
|
||||
def event_matches_levels(event: LogEvent, selected_levels: tuple[str, ...]) -> bool:
|
||||
if not selected_levels:
|
||||
return True
|
||||
return event.level in selected_levels
|
||||
|
||||
|
||||
def event_matches_date_range(event: LogEvent, start_date: str | None, end_date: str | None) -> bool:
|
||||
if not start_date and not end_date:
|
||||
return True
|
||||
if event.timestamp is None:
|
||||
return False
|
||||
date_token = event.timestamp.astimezone(UTC).date().isoformat()
|
||||
if start_date and date_token < start_date:
|
||||
return False
|
||||
if end_date and date_token > end_date:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def event_matches_search(event: LogEvent, search: str | None) -> bool:
|
||||
if search is None:
|
||||
return True
|
||||
query = search.strip().lower()
|
||||
if not query:
|
||||
return True
|
||||
return query in event.search_text
|
||||
|
||||
|
||||
def build_daily_log_markers(entries: list[StructuredLogEntry]) -> list[dict[str, Any]]:
|
||||
grouped: dict[str, list[StructuredLogEntry]] = {}
|
||||
for entry in entries:
|
||||
@@ -503,6 +762,69 @@ def build_daily_log_markers(entries: list[StructuredLogEntry]) -> list[dict[str,
|
||||
return [marker.__dict__ for marker in markers]
|
||||
|
||||
|
||||
def build_daily_log_markers_from_events(events: list[LogEvent]) -> list[dict[str, Any]]:
|
||||
grouped: dict[str, list[LogEvent]] = {}
|
||||
for event in events:
|
||||
if event.timestamp is None:
|
||||
continue
|
||||
date_token = event.timestamp.astimezone(UTC).date().isoformat()
|
||||
grouped.setdefault(date_token, []).append(event)
|
||||
|
||||
markers: list[DailyLogMarker] = []
|
||||
for date_token, group in sorted(grouped.items()):
|
||||
level_counts = Counter(
|
||||
event.level
|
||||
for event in group
|
||||
if event.level in SUPPORTED_LOG_LEVELS and event.level != LOG_LEVEL_ALL
|
||||
)
|
||||
dominant_level = LOG_LEVEL_INFO
|
||||
if level_counts:
|
||||
dominant_level = sorted(
|
||||
level_counts.items(),
|
||||
key=lambda item: (
|
||||
-item[1],
|
||||
("error", "warning", "info", "debug").index(item[0]),
|
||||
),
|
||||
)[0][0]
|
||||
markers.append(
|
||||
DailyLogMarker(
|
||||
date_token=date_token,
|
||||
total=len(group),
|
||||
dominant_level=dominant_level,
|
||||
)
|
||||
)
|
||||
return [marker.__dict__ for marker in markers]
|
||||
|
||||
|
||||
def read_log_events(
|
||||
source_id: str,
|
||||
scan_limit: int,
|
||||
*,
|
||||
level: str = LOG_LEVEL_ALL,
|
||||
levels: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[LogEvent] | None:
|
||||
source = LOG_SOURCES.get(source_id)
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
selected_levels = normalize_log_levels(level, levels)
|
||||
search_query = (search or "").strip()
|
||||
events = build_log_events(source_id, read_source_entries(source, scan_limit))
|
||||
marker_events = [
|
||||
event
|
||||
for event in events
|
||||
if event_matches_levels(event, selected_levels) and event_matches_search(event, search_query)
|
||||
]
|
||||
return [
|
||||
event
|
||||
for event in marker_events
|
||||
if event_matches_date_range(event, start_date, end_date)
|
||||
]
|
||||
|
||||
|
||||
def read_log_snapshot(
|
||||
source_id: str,
|
||||
limit: int,
|
||||
@@ -520,18 +842,18 @@ def read_log_snapshot(
|
||||
selected_levels = normalize_log_levels(level, levels)
|
||||
search_query = (search or "").strip()
|
||||
scan_limit = max(min(MAX_LOG_LINE_LIMIT * 5, 5000), limit * 5, BUFFER_LOG_LIMIT if source.kind == "buffer" else 1000)
|
||||
all_entries = read_source_entries(source, scan_limit)
|
||||
marker_entries = [
|
||||
entry
|
||||
for entry in all_entries
|
||||
if matches_levels(entry, selected_levels) and matches_search(entry, search_query)
|
||||
all_events = build_log_events(source_id, read_source_entries(source, scan_limit))
|
||||
marker_events = [
|
||||
event
|
||||
for event in all_events
|
||||
if event_matches_levels(event, selected_levels) and event_matches_search(event, search_query)
|
||||
]
|
||||
filtered_entries = [
|
||||
entry
|
||||
for entry in marker_entries
|
||||
if matches_date_range(entry, start_date, end_date)
|
||||
filtered_events = [
|
||||
event
|
||||
for event in marker_events
|
||||
if event_matches_date_range(event, start_date, end_date)
|
||||
]
|
||||
visible_entries = filtered_entries[-limit:]
|
||||
visible_events = filtered_events[-limit:]
|
||||
|
||||
compatibility_level = selected_levels[0] if len(selected_levels) == 1 else LOG_LEVEL_ALL
|
||||
return {
|
||||
@@ -552,8 +874,8 @@ def read_log_snapshot(
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_DEBUG,
|
||||
],
|
||||
"daily_markers": build_daily_log_markers(marker_entries),
|
||||
"daily_markers": build_daily_log_markers_from_events(marker_events),
|
||||
"line_limit": limit,
|
||||
"line_count": len(visible_entries),
|
||||
"lines": [entry.display_line for entry in visible_entries],
|
||||
"line_count": len(visible_events),
|
||||
"lines": [event.line for event in visible_events],
|
||||
}
|
||||
|
||||
@@ -672,6 +672,39 @@ async def test_ingest_earth_client_log_accepts_public_events():
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ingest_admin_client_log_accepts_public_events():
|
||||
transport = ASGITransport(app=app)
|
||||
try:
|
||||
with patch("app.api.v1.system_control.append_buffer_log") as mock_append_buffer_log:
|
||||
with patch("app.api.v1.system_control.record_system_log", new_callable=AsyncMock) as mock_record_system_log:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(
|
||||
"/api/v1/system/logs/admin-client",
|
||||
json={
|
||||
"level": "error",
|
||||
"message": "控制台发生未处理 Promise 错误",
|
||||
"category": "unhandledrejection",
|
||||
"module": "admin",
|
||||
"url": "http://test/logs",
|
||||
"detail": "stack preview",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["accepted"] is True
|
||||
assert data["source_id"] == "admin-client"
|
||||
mock_append_buffer_log.assert_called_once()
|
||||
mock_record_system_log.assert_awaited_once()
|
||||
persisted_kwargs = mock_record_system_log.await_args.kwargs
|
||||
assert persisted_kwargs["source"] == "admin-client"
|
||||
assert persisted_kwargs["event"] == "admin.client.runtime_log"
|
||||
assert persisted_kwargs["category"] == "unhandledrejection"
|
||||
assert persisted_kwargs["context"]["url"] == "http://test/logs"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_earth_layer_cache_status_requires_super_admin(auth_headers, monkeypatch):
|
||||
def override_get_current_user():
|
||||
|
||||
@@ -40,10 +40,10 @@ def test_read_log_snapshot_uses_structured_buffer_timestamp_level_and_search(mon
|
||||
{
|
||||
"earth-client": system_logs.LogSource(
|
||||
source_id="earth-client",
|
||||
name="Earth 浏览器端",
|
||||
name="智能星球浏览器端",
|
||||
kind="buffer",
|
||||
location="redis://planet:system_logs:earth-client",
|
||||
description="Earth 浏览器端上报日志",
|
||||
description="智能星球浏览器端上报日志",
|
||||
category="client",
|
||||
buffer_key=system_logs.get_buffer_log_key("earth-client"),
|
||||
)
|
||||
@@ -156,6 +156,52 @@ def test_append_buffer_log_persists_normalized_level(monkeypatch):
|
||||
assert payload["message"] == "feed delayed"
|
||||
|
||||
|
||||
def test_list_log_sources_includes_admin_client(monkeypatch):
|
||||
fake_redis = FakeRedis()
|
||||
monkeypatch.setattr(system_logs, "redis_client", fake_redis)
|
||||
|
||||
sources = system_logs.list_log_sources()
|
||||
|
||||
admin_source = next(item for item in sources if item["source_id"] == "admin-client")
|
||||
assert admin_source["kind"] == "buffer"
|
||||
assert admin_source["category"] == "client"
|
||||
|
||||
|
||||
def test_read_log_events_returns_stable_cursors(tmp_path: Path, monkeypatch):
|
||||
log_path = tmp_path / "backend.log"
|
||||
log_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"2026-04-22 08:00:00 INFO service booted",
|
||||
"2026-04-22 08:01:00 ERROR service failed",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
system_logs,
|
||||
"LOG_SOURCES",
|
||||
{
|
||||
"backend": system_logs.LogSource(
|
||||
source_id="backend",
|
||||
name="后端服务",
|
||||
kind="file",
|
||||
location=str(log_path),
|
||||
description="测试文件日志",
|
||||
category="service",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
events = system_logs.read_log_events("backend", 50, level="error")
|
||||
|
||||
assert events is not None
|
||||
assert len(events) == 1
|
||||
assert events[0].source_id == "backend"
|
||||
assert events[0].cursor.startswith("backend:")
|
||||
assert events[0].line.endswith("ERROR service failed")
|
||||
|
||||
|
||||
def test_infer_log_level_prefers_leading_prefix_over_query_string():
|
||||
line = 'INFO: 127.0.0.1 - "GET /api/v1/system/logs/backend?limit=200&level=error&levels=error HTTP/1.1" 200 OK'
|
||||
|
||||
|
||||
@@ -8,6 +8,53 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.67.0] — 2026-05-27
|
||||
|
||||
Released: 2026-05-27
|
||||
|
||||
### Highlights
|
||||
- 新增控制台日志实时跟随与前端运行时错误上报,帮助在控制台内直接排查 Admin / Earth 客户端异常。
|
||||
- 重构智能星球 Interactable 聚合和 wheel 缩放输入,保持真实地理锚点稳定,同时让鼠标滚轮和触控板拥有各自合适的缩放手感。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `/ws` 日志 tail 通道、数据库/文件日志统一事件读取,以及 Admin `error` / `unhandledrejection` / React ErrorBoundary 上报链路。
|
||||
- 优化控制台日志页状态颜色、跟随体验和运行时错误展示,并将 Admin 本地工具模块从 `lib` 命名迁移为局部 `utils`。
|
||||
- 修复智能星球国界/高清材质壳半径对齐、Interactable cluster 圆点显示、缩放目标累积和触控板连续缩放问题。
|
||||
- 更新 `planet.sh` 与 `.gitignore`,避免新环境构建污染锁文件并移除前端 `lib` 目录特殊放行。
|
||||
- 补充智能星球前端、渲染层级、控制台状态与运行时日志文档。
|
||||
|
||||
---
|
||||
|
||||
## [0.66.3] — 2026-05-26
|
||||
|
||||
Released: 2026-05-26
|
||||
|
||||
### Highlights
|
||||
- 修复控制台 Admin 动态导入时缺失 `../lib/utils` 导致 Vite 返回 500 的问题。
|
||||
- 补齐前端依赖安装状态,确保 Markdown Mermaid 渲染器可解析 `mermaid` 包。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 Admin 本地 `cn` 与 `formatNumber` 工具模块,恢复布局、UI 组件和 Dashboard 的共享工具引用。
|
||||
- 放开 `.gitignore` 中 `frontend/src/admin/lib` 的源码例外,避免 utility module 再次被全局 `lib/` 规则漏提交。
|
||||
- 重启前端开发服务并验证 `/admin` 无 Vite overlay 和 console error。
|
||||
|
||||
---
|
||||
|
||||
## [0.66.2] — 2026-05-26
|
||||
|
||||
Released: 2026-05-26
|
||||
|
||||
### Highlights
|
||||
- 统一中文界面和公开文档中的产品命名:`Earth` 显示为“智能星球”,`Admin` 显示为“控制台”,`Docs` 显示为“文档”。
|
||||
- 修复文档手册、Docs catalog、控制台入口和智能星球设置中残留的中英混排标题。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 更新控制台侧边栏、智能星球系统设置、移动端抽屉和登录页品牌文案。
|
||||
- 同步前后端 Docs metadata、中文技术文档标题、使用手册、快速开始、术语表和运维手册中的产品命名。
|
||||
- 补充清理智能星球图层缓存、品牌配置、展示缓存等控制台 toast / dialog 文案。
|
||||
|
||||
---
|
||||
|
||||
## [0.66.1] — 2026-05-26
|
||||
|
||||
Released: 2026-05-26
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
状态:Phase 1 已经开始落地,Phase 2 的 BGP 事件 / 观测站迁移和 Phase 3 的算力中心迁移也已完成。`frontend/public/earth/js/interactable.js` 已新增,AIS 船只、BGP 事件、BGP 观测站和算力中心图层已经改为通过 `createInteractableLayer()` 使用通用批量 `Points`、hover / locked overlay、默认 glow、状态更新、asset icon 预加载、屏幕空间 picking、固定 / 距离缩放和跨 Interactable 同坐标避让。登陆点因 `THREE.Points` 边缘深度裁切和贴地层级要求,已退回专用 `THREE.Sprite` 黄色球路径,并与海缆同高度同 renderOrder。后续阶段聚焦把可复用的扩圈 / 雷达扇形动画正式沉淀成 `animations` 扩展。
|
||||
状态:Phase 1 已经开始落地,Phase 2 的 BGP 事件 / 观测站迁移和 Phase 3 的算力中心迁移也已完成。`frontend/public/earth/js/interactable.js` 已新增,AIS 船只、BGP 事件、BGP 观测站和算力中心图层已经改为通过 `createInteractableLayer()` 使用通用批量 `Points`、hover / locked overlay、默认 glow、状态更新、asset icon 预加载、屏幕空间 picking、固定 / 距离缩放和跨 Interactable 同坐标关系元数据。登陆点因 `THREE.Points` 边缘深度裁切和贴地层级要求,已退回专用 `THREE.Sprite` 黄色球路径,并与海缆同高度同 renderOrder。后续阶段聚焦把可复用的扩圈 / 雷达扇形动画正式沉淀成 `animations` 扩展。
|
||||
|
||||
当前实现说明和接入示例见:
|
||||
|
||||
@@ -100,9 +100,10 @@ createInteractableLayer({
|
||||
| `picking.throttleMs` | `100` | `80` | hover picking 节流。 |
|
||||
| `picking.skipWhileDragging` | `true` | `true` | 拖动和惯性期间跳过 hover picking。 |
|
||||
| `zIndexPolicy` | `"surface-icon"` | `"surface-icon"` | 预设层级策略,避免每个业务图层手写高度和 renderOrder。 |
|
||||
| `avoidance.enabled` | `true / false` | `true` | 是否参与跨 Interactable 的同坐标避让。默认开启,同一经纬度下的图标会沿地表切平面小幅排开,方便辨认和选择。 |
|
||||
| `avoidance.radius` | `number` | `1.1` | 同坐标避让的第一圈半径,单位为地球本地坐标单位。 |
|
||||
| `avoidance.enabled` | `true / false` | `true` | 是否记录跨 Interactable 的同坐标关系。该配置只生成 overlap 元数据,不允许移动真实 marker 坐标。 |
|
||||
| `avoidance.precision` | `number` | `4` | 经纬度归并精度,默认约等于只处理几乎完全重叠的图标。 |
|
||||
| `cluster.enabled` | `true / false` | 跟随 avoidance | 是否参与跨 Interactable 的当前帧屏幕重叠合并。只影响显示,不改变业务坐标。 |
|
||||
| `cluster.maxMarkersPerDot` | `number` | `14` | 单个聚合圆点的对象上限;超过后按屏幕局部邻近关系拆成多个较小圆点,避免一个点过大或跨区域串联。 |
|
||||
| `legend` | `{ label, color, shape }[]` | `[]` | 可选图例声明,业务层也可以继续自己导出。 |
|
||||
| `metadata` | object | `{}` | 业务扩展数据,不参与渲染但参与 tooltip / info-card / search。 |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Technical Docs
|
||||
|
||||
This is the current Planet documentation entry point. Docs are organized by reader path: start with business architecture to understand data products, then move into user manuals or implementation references.
|
||||
This is the current Intelligent Planet documentation entry point. Docs are organized by reader path: start with business architecture to understand data products, then move into user manuals or implementation references.
|
||||
|
||||
## Business Architecture
|
||||
|
||||
@@ -9,8 +9,8 @@ This is the current Planet documentation entry point. Docs are organized by read
|
||||
|
||||
## Manual
|
||||
|
||||
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): shortest path to getting Planet running from scratch
|
||||
- [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): user workflows for the console, Earth, Docs, and common features
|
||||
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md): shortest path to getting Intelligent Planet running from scratch
|
||||
- [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md): user workflows for the console, Earth, Docs, and common features
|
||||
- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md): troubleshooting for Windows / WSL, ports, dependencies, motion capture, credentials, and Docs permissions
|
||||
|
||||
## Earth Implementation
|
||||
@@ -43,7 +43,7 @@ This is the current Planet documentation entry point. Docs are organized by read
|
||||
## Agents and Operations
|
||||
|
||||
- [AI Provider Guide](/home/ray/dev/linkong/planet/docs/technical/en/agents-aiprovider.md): model provider adapters, task prompts, and invocation boundaries
|
||||
- [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md): deployment, startup, troubleshooting, and sensitive operations
|
||||
- [Intelligent Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md): deployment, startup, troubleshooting, and sensitive operations
|
||||
- [Docker + Compose + Buildx Upgrade](/home/ray/dev/linkong/planet/docs/technical/en/ops-docker-compose-buildx-upgrade.md): Docker toolchain upgrade steps
|
||||
- [planet.sh Startup](/home/ray/dev/linkong/planet/docs/technical/en/ops-planet-sh-startup.md): startup script, health checks, and performance optimization
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Docs Gatekeeper moves `/docs` from "bundle all Markdown into the frontend" to "return catalog and content from the backend according to permissions." Its goal is to keep public manuals, user docs, developer docs, and admin/ops docs in one searchable Docs page while making every protected Markdown body pass through a server-side whitelist and authorization check.
|
||||
|
||||
For the user workflow, see the Docs section in [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md).
|
||||
For the user workflow, see the Docs section in [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md).
|
||||
|
||||
## Authorization Model
|
||||
|
||||
@@ -61,8 +61,8 @@ DocsMetadata(
|
||||
"public",
|
||||
"Manual",
|
||||
2,
|
||||
"Planet 使用手册",
|
||||
"Planet Manual",
|
||||
"智能星球使用手册",
|
||||
"Intelligent Planet Manual",
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ Responsibilities:
|
||||
- Terrain tile fetch, decode, displacement, and shading
|
||||
- Whole-globe land/ocean and border base overlays
|
||||
|
||||
The Earth surface is a stack of near-concentric shells, not a single mesh. The base sphere and HD texture overlay in `earth.js`, plus the land/ocean base in `country-boundaries.js`, need explicit radius separation. At far zoom, GPU depth precision drops; neighboring shells that are too close can z-fight and show black flicker blocks or snow. The current stable spacing is `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`. When adding or adjusting whole-globe surface overlays, update [Earth Render Layer Order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md) and verify at 50% zoom.
|
||||
The Earth surface is a stack of near-concentric shells, not a single mesh. The base sphere and HD texture overlay in `earth.js`, plus the land/ocean base in `country-boundaries.js`, need explicit radius separation. At far zoom, GPU depth precision drops; neighboring shells that are too close can z-fight and show black flicker blocks or snow. The current stable spacing is `landAltitudeOffset = 0.32` and `EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` for the HD texture shell. Country borders, coastlines, claim lines, and country hover lines must use that same HD texture shell radius so they do not drift relative to the texture while the globe rotates. When adding or adjusting whole-globe surface overlays, update [Earth Render Layer Order](/home/ray/dev/linkong/planet/docs/technical/en/earth-render-layer-order.md) and verify at 50% zoom.
|
||||
|
||||
### 7. Layer Modules
|
||||
|
||||
@@ -313,6 +313,16 @@ If future cable, satellite, or news cruise is added, do not copy a new set of `m
|
||||
- [callout-connector.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/callout-connector.js)
|
||||
- The business adapter pattern from [bgp-cruise-adapter.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/bgp-cruise-adapter.js)
|
||||
|
||||
## View Control Feedback
|
||||
|
||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) owns the Earth zoom state, and every zoom entry point must ultimately call `setZoomLevel()` to write the camera distance. Do not write `camera.position.z` from other modules, or the zoom percentage, drag sensitivity, and Interactable clustering thresholds will diverge again.
|
||||
|
||||
Wheel input has two paths. Traditional mouse wheels keep the 10% step and short animation, using `wheelZoomTarget` as the logical base for continuous wheel input. Trackpads and high-precision wheels use the pixel delta for continuous zoom and call `setZoomLevel()` directly instead of passing through the 10% stepped animation. The trackpad path also filters a short-window, old-direction residual delta after a real direction change so inertia tails do not pull a just-reversed zoom back in the previous direction.
|
||||
|
||||
The gesture capsule updates at most once every 90ms and fades after 760ms. It is view feedback, not data loading progress, and should not be written into layer loading state.
|
||||
|
||||
[main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) only calls `setZoomLevel()` and `showZoomStatusCapsule()` for pinch zoom and motion zoom. Mouse wheel and zoom-button capsule feedback should stay in `controls.js` so the same zoom feedback does not spread across modules.
|
||||
|
||||
## Recommended Change Approach
|
||||
|
||||
For future Earth changes:
|
||||
|
||||
@@ -18,14 +18,14 @@ Note: the layer control panel order and the registration / startup load order ar
|
||||
| 0 | Earth base sphere | `earth.js` | `CONFIG.earthRadius` | Surface picking fallback target | Dark base; still visible when all optional map layers are off. |
|
||||
| 0.2 | Country dark tint | `country-boundaries.js` | `tintAltitudeOffset` | Raycast disabled | Used when HD texture is off. |
|
||||
| 0.86 | Land/ocean base fill | `country-boundaries.js` | `landAltitudeOffset = 0.32`; ocean `#010609`, land `#080f1b` | Raycast disabled | Base map remains usable even when country borders are off; radius is separated from the base sphere to avoid far-zoom z-fighting. |
|
||||
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
|
||||
| 0.96 | HD Earth texture | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | Surface picking target when visible | HD texture always overlays the land/ocean base fill; radius must stay above the land/ocean base and far enough from the base sphere. |
|
||||
| 1 | Atmospheric glow and clouds | `earth.js` | Atmosphere / cloud spheres | Not in normal object selection path | Cloud layer controlled by the "Cloud Layer" toggle. |
|
||||
| 1 | Submarine cables | `cables.js` | `CABLE_CONFIG.line.renderOrder` | Cable picking path | Preserves existing cable layer level. |
|
||||
| 1.2 | Real terrain | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` plus terrain displacement | Raycast disabled | Terrain overlays HD texture; temporarily hidden when HD texture is off, restores to prior state when re-enabled. |
|
||||
| 2.05 | Grid lines | `earth.js` | `CONFIG.earthRadius + 0.14` | Raycast disabled | Low-opacity lines over HD texture. |
|
||||
| 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset` | Raycast disabled | Only needs to stay above HD texture. |
|
||||
| 2.2 | Country borders | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`; claim lines have no extra lift | Raycast disabled | Line geometry still has its own `renderOrder`, but it shares the exact same radius as the HD texture shell to avoid parallax while the globe rotates. |
|
||||
| 2.29 | Country border hover glow | `country-boundaries.js` | Hover radius + glow offset | `depthTest: false`, raycast disabled | Additive glow to reinforce border edge and terrain hover visibility. |
|
||||
| 2.3 | Country border hover line | `country-boundaries.js` | `hoverAltitudeOffset` | `depthTest: false`, raycast disabled | Neon red-orange hover line; China and Taiwan share the same highlight group. |
|
||||
| 2.3 | Country border hover line | `country-boundaries.js` | `hoverAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | `depthTest: false`, raycast disabled | Neon red-orange hover line; aligned with the normal borders and HD texture shell to avoid ghosting or floating; China and Taiwan share the same highlight group. |
|
||||
| 3 | Satellite footprint fill / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested; Iridium adapter fill / ring use the same renderOrder | Footprint above land / texture / terrain and country borders, below compute centers and satellites. |
|
||||
| 3-5 | BGP markers and overlays | `bgp.js` | Each marker's own renderOrder | BGP picking path | Preserves existing BGP visual level. |
|
||||
| 4.5 | Compute centers | `compute-centers.js` | `COMPUTE_CENTER_RENDER_ORDER` | Compute center picking path | Surface facilities, below satellites. |
|
||||
@@ -52,7 +52,8 @@ The Earth surface is not a single mesh. It is a stack of near-concentric shells:
|
||||
Maintenance rules:
|
||||
|
||||
- Do not reach first for hiding layers at far zoom. Check neighboring shell `altitudeOffset`, `renderOrder`, `depthTest`, and `depthWrite` first.
|
||||
- Whole-globe overlays such as the land/ocean base and HD texture must keep explicit separation from `CONFIG.earthRadius`; the current stable values are `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = 0.48`.
|
||||
- Whole-globe overlays such as the land/ocean base and HD texture must keep explicit separation from `CONFIG.earthRadius`; the current stable values are `landAltitudeOffset = 0.32` and `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`.
|
||||
- Country borders, coastlines, claim lines, and country hover lines must use `EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET`, exactly matching the HD texture shell. Do not give border lines an independent lower or higher offset, because globe rotation will make the lines appear to drift relative to the surface texture.
|
||||
- Any new whole-globe or near-whole-globe surface overlay must be screenshot-verified at 50% zoom and at common close zooms, with no black blocks, snow, flicker, or obvious floating.
|
||||
- If these radii change, update this document and the intent around the constants in `frontend/public/earth/js/constants.js`.
|
||||
|
||||
|
||||
@@ -92,6 +92,31 @@ The Admin sidebar theme switcher still reuses shared [SegmentedControl.tsx](/hom
|
||||
- Dark mode uses the same dark base, `#202938` slider, and dark external shadow semantics as Docs.
|
||||
- Product code only hides the text label and keeps icon + tooltip behavior; it should not recreate the private slider DOM.
|
||||
|
||||
## Admin Status Colors
|
||||
|
||||
Admin status labels should use [StatusText](/home/ray/dev/linkong/planet/frontend/src/admin/patterns/patterns.tsx) or [Badge](/home/ray/dev/linkong/planet/frontend/src/admin/components/ui/badge.tsx). Color variables come from [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin/styles.css). New states should map to an existing tone instead of adding page-local hex colors.
|
||||
|
||||
`StatusText` is an indicator-light pill: the pill background and border stay on the component base color, while only the dot and text use the status color. `Badge` does not carry the indicator-light meaning, so it may use a light same-tone background and border for stronger hierarchy.
|
||||
|
||||
| Tone | Color variable | Meaning | Examples |
|
||||
| --- | --- | --- | --- |
|
||||
| `success` | `--an-success` | available, successful, connected, enabled | log source `Available`, collection `Success` |
|
||||
| `warning` | `--an-warning` | needs attention but is not necessarily failed | missing log file, degraded or skipped state |
|
||||
| `danger` | `--an-danger` | failed, unavailable, permission/connection error | `Docker unavailable`, endpoint failure |
|
||||
| `info` / `running` | `--an-info` | in progress, syncing, informational | `Following`, `Syncing` |
|
||||
| `neutral` | `--an-muted` | empty, disabled, not reported yet, unknown | `No reports yet`, disabled |
|
||||
| `ai` | fixed purple | AI-specific emphasis | AI generation or model action |
|
||||
|
||||
The log source list follows the same rule: `ok` is success; `empty` means the source exists but has not reported yet and is neutral; `missing` means an expected log file is not present and is warning; `docker_unavailable` / `source_unavailable` are danger.
|
||||
|
||||
## Admin Runtime Logs
|
||||
|
||||
Admin runtime errors are reported through [runtimeLogs.ts](/home/ray/dev/linkong/planet/frontend/src/admin/runtimeLogs.ts) to `/api/v1/system/logs/admin-client`. The reporter only runs on Admin routes and skips `/earth`, `/docs`, login, and registration pages so public-page noise does not enter the Admin client log source.
|
||||
|
||||
[AdminErrorBoundary.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/components/AdminErrorBoundary.tsx) catches React render failures and reuses the same reporter; global `error` and `unhandledrejection` events use that channel as well. Reporting failures must stay silent, because the logging path must not create another frontend error.
|
||||
|
||||
The Logs page follows log increments through the `/ws` `logs_tail` channel. File logs and database logs are both normalized into line events by the backend. When adding a new log source, wire it through the backend source registry and tail manager instead of adding a page-local poller.
|
||||
|
||||
## Current Shared Components
|
||||
|
||||
### 1. `Scrollbar`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`backend/app/services/location/` is the shared abstraction for any "given a record, decide its lat/lon" workflow. Compute centers, BGP collectors, and BGP events now run on this pipeline. Future entities such as satellite ground stations, user-claimed points, and IXP facilities should plug in here instead of creating another geocoding path.
|
||||
|
||||
For the user workflow, see the Earth coordinate-candidate section in [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md).
|
||||
For the user workflow, see the Earth coordinate-candidate section in [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md).
|
||||
|
||||
## Design Goals
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Planet Manual
|
||||
# Intelligent Planet Manual
|
||||
|
||||
This manual is for Planet end users. Starting from the browser, it covers account registration, login, configuring collectors, configuring AI, using Earth and the console, and reading the docs site. Every action happens in a browser.
|
||||
This manual is for Intelligent Planet end users. Starting from the browser, it covers account registration, login, configuring collectors, configuring AI, using Intelligent Planet and the console, and reading the docs site. Every action happens in a browser.
|
||||
|
||||
If you are responsible for deployment or on-call duty, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead — it covers shell commands, log paths, and CLI fallbacks for user creation.
|
||||
If you are responsible for deployment or on-call duty, read the [Intelligent Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead — it covers shell commands, log paths, and CLI fallbacks for user creation.
|
||||
|
||||
## Entry Overview
|
||||
|
||||
@@ -375,4 +375,4 @@ Docs supports: category navigation, Markdown rendering, tables and code blocks,
|
||||
|
||||
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md)
|
||||
- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md)
|
||||
- [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)
|
||||
- [Intelligent Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Naming Glossary
|
||||
|
||||
This document standardizes terms used across the Planet console, Earth, backend APIs, and documentation. When adding Chinese UI labels, chart labels, or translated documentation, prefer the Chinese display names listed here to avoid unnecessary mixed Chinese/English copy.
|
||||
This document standardizes terms used across Intelligent Planet, the console, backend APIs, and documentation. When adding Chinese UI labels, chart labels, or translated documentation, prefer the Chinese display names listed here to avoid unnecessary mixed Chinese/English copy.
|
||||
|
||||
## Usage Rules
|
||||
|
||||
@@ -14,9 +14,9 @@ This document standardizes terms used across the Planet console, Earth, backend
|
||||
|
||||
| English / Key | Chinese Display Name | Usage |
|
||||
| --- | --- | --- |
|
||||
| Planet | Planet | Product name |
|
||||
| Planet | 智能星球 | Product name and primary experience entry |
|
||||
| Admin | 控制台 | Admin console context |
|
||||
| Earth | Earth | Visualization product name |
|
||||
| Earth | 智能星球 | Visualization product name and `/earth` entry |
|
||||
| datasource | 数据源 | APIs, lists, filters |
|
||||
| collector | 采集器 | Collection jobs and credential configuration |
|
||||
| collected data | 采集数据 | Data list and statistics |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Planet Ops Runbook
|
||||
# Intelligent Planet Ops Runbook
|
||||
|
||||
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
|
||||
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
|
||||
|
||||
## First Startup
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Business Architecture and Data Flows
|
||||
|
||||
This document is the business entry point for Planet data products. It explains why each Earth data type exists, where it is collected from, which fact or derived tables it uses, and how cache invalidation plus WebSocket hints reach Earth. Frontend, backend, and Earth docs should focus on implementation details; start here when you need the cross-system data flow.
|
||||
This document is the business entry point for Intelligent Planet data products. It explains why each Earth data type exists, where it is collected from, which fact or derived tables it uses, and how cache invalidation plus WebSocket hints reach Earth. Frontend, backend, and Earth docs should focus on implementation details; start here when you need the cross-system data flow.
|
||||
|
||||
## Overview
|
||||
|
||||
Planet data moves through three stages:
|
||||
Intelligent Planet data moves through three stages:
|
||||
|
||||
1. **Collect and normalize**: built-in collectors, admin actions, or the location pipeline write PostgreSQL. Generic raw output lands in `collected_data`; layer-ready projections land in derived tables.
|
||||
2. **Project and broadcast**: database triggers write fact changes to the `earth_data_change_events` outbox and wake the backend listener with `LISTEN/NOTIFY`. The listener maps the change through layer adapters, invalidates cache, and broadcasts `earth_updates`.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Quickstart
|
||||
|
||||
This quickstart is for Planet end users who just received an access URL and need the shortest path from "open the browser" to "first useful configuration done". Every action happens in the browser.
|
||||
This quickstart is for Intelligent Planet end users who just received an access URL and need the shortest path from "open the browser" to "first useful configuration done". Every action happens in the browser.
|
||||
|
||||
If you are responsible for deployment or operations, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead.
|
||||
If you are responsible for deployment or operations, read the [Intelligent Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead.
|
||||
|
||||
## 1. Open the URL
|
||||
|
||||
@@ -62,7 +62,7 @@ Open `/forgot-password`, enter your email, receive a code, then enter the code p
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Full UI walkthrough: [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
|
||||
- Full UI walkthrough: [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
|
||||
- Troubleshooting and configuration questions: [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md)
|
||||
- Detailed Earth coordinate candidate flow: see the Earth section in [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
|
||||
- Deployment / operations commands: [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)
|
||||
- Detailed Earth coordinate candidate flow: see the Earth section in [Intelligent Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
|
||||
- Deployment / operations commands: [Intelligent Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
# 技术文档
|
||||
|
||||
这里是 Planet 当前文档入口。文档按读者和问题类型分层:先看业务架构理解数据产品,再进入使用手册或技术实现文档。
|
||||
这里是智能星球当前文档入口。文档按读者和问题类型分层:先看业务架构理解数据产品,再进入使用手册或技术实现文档。
|
||||
|
||||
## 业务架构
|
||||
|
||||
- [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md):每类 Earth 数据的用途、采集链路、事实表、派生表、缓存和 WebSocket 广播链路
|
||||
- [命名与术语对照](/home/ray/dev/linkong/planet/docs/technical/zh/naming-glossary.md):控制台、Earth、后端和文档常见名词的中英对照
|
||||
- [业务架构与数据流转](/home/ray/dev/linkong/planet/docs/technical/zh/platform-data-flows.md):每类智能星球数据的用途、采集链路、事实表、派生表、缓存和 WebSocket 广播链路
|
||||
- [命名与术语对照](/home/ray/dev/linkong/planet/docs/technical/zh/naming-glossary.md):智能星球、控制台、后端和文档常见名词的中英对照
|
||||
|
||||
## 使用手册
|
||||
|
||||
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动 Planet 的最短路径
|
||||
- [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、Earth、Docs 和常用功能的用户操作说明
|
||||
- [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md):Windows / WSL、端口、依赖、动捕、凭证和 Docs 权限排障
|
||||
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md):从零启动智能星球的最短路径
|
||||
- [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md):控制台、智能星球、文档和常用功能的用户操作说明
|
||||
- [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md):Windows / WSL、端口、依赖、动捕、凭证和文档权限排障
|
||||
|
||||
## Earth 技术实现
|
||||
## 智能星球技术实现
|
||||
|
||||
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md):Earth 页面模块、状态、WebSocket 刷新和图层生命周期
|
||||
- [Earth 图层样式属性索引](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md):图层颜色、符号、材质和视觉参数
|
||||
- [Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md):renderOrder、深度策略、拾取和同坐标避让
|
||||
- [Earth 卫星覆盖策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-satellite-footprint-policy.md):卫星 footprint 的显示边界和策略
|
||||
- [BGP 态势上下文](/home/ray/dev/linkong/planet/docs/technical/zh/earth-bgp-context.md):BGP 在 Earth 中的渲染、聚合和观测站实现
|
||||
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):`Interactable` 的接口、生命周期和接入示例
|
||||
- [Earth 工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索、设置、新闻、图层浮层的关闭矩阵
|
||||
- [智能星球前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md):智能星球页面模块、状态、WebSocket 刷新和图层生命周期
|
||||
- [智能星球图层样式属性索引](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md):图层颜色、符号、材质和视觉参数
|
||||
- [智能星球渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md):renderOrder、深度策略、拾取和同坐标避让
|
||||
- [智能星球卫星覆盖策略](/home/ray/dev/linkong/planet/docs/technical/zh/earth-satellite-footprint-policy.md):卫星 footprint 的显示边界和策略
|
||||
- [BGP 态势上下文](/home/ray/dev/linkong/planet/docs/technical/zh/earth-bgp-context.md):BGP 在智能星球中的渲染、聚合和观测站实现
|
||||
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md):`Interactable` 的接口、生命周期和接入示例
|
||||
- [智能星球工具栏与浮层协同](/home/ray/dev/linkong/planet/docs/technical/zh/earth-toolbar-overlay-coordination.md):工具栏按钮与搜索、设置、新闻、图层浮层的关闭矩阵
|
||||
|
||||
## 前端技术实现
|
||||
|
||||
@@ -38,12 +38,12 @@
|
||||
- [数据作业与 Outbox 技术架构](/home/ray/dev/linkong/planet/docs/technical/zh/data-job-earth-sync-architecture.md):PostgreSQL 作业队列、outbox、listener 和 Kafka / Spark 演进边界
|
||||
- [通用位置估算管线开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-development.md):location resolver / pipeline 的接口、注册表和扩展方式
|
||||
- [新闻直播采集格式](/home/ray/dev/linkong/planet/docs/technical/zh/earth-news-live-streams-collector-format.md):新闻、直播和媒体采集 payload 约定
|
||||
- [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md):后端 Docs 目录、正文读取和 Gatekeeper 权限组实现
|
||||
- [Docs Gatekeeper 开发说明](/home/ray/dev/linkong/planet/docs/technical/zh/docs-gatekeeper-development.md):后端文档目录、正文读取和 Gatekeeper 权限组实现
|
||||
|
||||
## 智能体与运维
|
||||
|
||||
- [AI Provider 指南](/home/ray/dev/linkong/planet/docs/technical/zh/agents-aiprovider.md):模型供应商适配、任务 prompt 和调用边界
|
||||
- [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md):部署、启动、排障和敏感操作
|
||||
- [智能星球运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md):部署、启动、排障和敏感操作
|
||||
- [Docker + Compose + Buildx 升级](/home/ray/dev/linkong/planet/docs/technical/zh/ops-docker-compose-buildx-upgrade.md):Docker 工具链升级步骤
|
||||
- [planet.sh 启动机制](/home/ray/dev/linkong/planet/docs/technical/zh/ops-planet-sh-startup.md):启动脚本、健康检查和性能优化
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ async def run(self, db):
|
||||
|
||||
AIS 船只类采集器和其它 `CollectedData` 采集器的落库路径不同。BarentsWatch、AISStream 和自定义 `vessel_ais` 源都会进入 AIS 原始观测层,随后由聚合服务合并成 Earth 船只图层使用的 GeoJSON 和详情数据。这样做可以保留来源、传输方式、字段冲突和观测时间,避免某个实时源直接覆盖最终展示表。
|
||||
|
||||
Earth 国界不再属于采集器体系。它是 Earth 静态渲染资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback,不会向 `CollectedData` 写入国界记录。
|
||||
智能星球国界不再属于采集器体系。它是智能星球静态渲染资产,由控制台 `运维与配置 -> 智能星球内容 -> 国界精度` 维护源配置,并由 `/api/v1/earth/boundaries/*` 构建 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles`。本地没有高精 PMTiles 时,前端会使用仓库内置的低精度 GeoJSON 作为 fallback,不会向 `CollectedData` 写入国界记录。
|
||||
|
||||
TOP500 和 Epoch AI 算力数据的公开源不总是提供可用经纬度。Earth 统一算力中心接口在主地图启动链路中只使用源数据自带坐标或 `compute_center_locations` 维表坐标;缺少坐标的记录会进入 `unresolved`,不会通过本地注册表、国家质心或猜测城市自动渲染。用户手动采集候选时,后端会用源字段调用 ROR 组织注册 API 和 Nominatim/OpenStreetMap 在线搜索;候选经前端保存后写入 `compute_center_locations`,后续地图刷新再从维表渲染。
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ DB 变化不再默认创建 `earth_refresh` 任务,因此不会被同 source
|
||||
| `collect` | 执行内置 datasource 采集 |
|
||||
| `clear_data` | 删除该 source 的采集数据和声明过的派生数据 |
|
||||
| `clear_cache` | 删除该 source 对应的 Earth / dashboard 缓存 |
|
||||
| `earth_refresh` | 非 DB 变化场景下失效 Earth 图层缓存并广播刷新提示 |
|
||||
| `earth_refresh` | 非 DB 变化场景下失效智能星球图层缓存并广播刷新提示 |
|
||||
|
||||
接口只创建任务并返回 `task_id`。任务执行、进度、取消和终态由 worker 写回 `collection_tasks`,并通过 `datasource_tasks` channel 通知前端。
|
||||
|
||||
@@ -138,4 +138,3 @@ ORDER BY 2, 1;
|
||||
- 原始数据进入 Parquet / Iceberg / Delta 等湖仓,并开始生产离线派生数据产品。
|
||||
|
||||
若目标是秒级连续流计算,优先评估 Flink;Spark 更适合批量或微批分析。
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=
|
||||
|
||||
自定义源现在不是独立的新数据孤岛,而是作为内置数据源的补充源写入目标 schema。当前最完整的目标是 `vessel_ais`:自定义 REST 或 WebSocket 源经过确定性 mapping 后写入 AIS raw observations,再通过 `vessels` WebSocket channel 推送给 Earth。
|
||||
|
||||
Earth 高精度边界不再使用自定义源目标 schema。国界是 Earth 静态资产,由控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存本机源配置并触发 PMTiles 构建,不写入 `CollectedData`。
|
||||
智能星球高精度边界不再使用自定义源目标 schema。国界是智能星球静态资产,由控制台 `运维与配置 -> 智能星球内容 -> 国界精度` 保存本机源配置并触发 PMTiles 构建,不写入 `CollectedData`。
|
||||
|
||||
### 配置语义
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Docs Gatekeeper 把 `/docs` 从“前端构建时打包所有 Markdown”改成“后端按权限返回目录和正文”。它的目标是让公开使用手册、用户文档、开发文档和管理/运维文档在同一个 Docs 页面内可检索,但正文读取必须经过服务端白名单和用户权限检查。
|
||||
|
||||
用户侧说明见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Docs 章节。
|
||||
用户侧说明见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的文档章节。
|
||||
|
||||
## 鉴权模型
|
||||
|
||||
@@ -16,7 +16,7 @@ Docs 使用两层权限:
|
||||
| 组 | 用途 |
|
||||
| --- | --- |
|
||||
| `docs_user` | 用户操作类文档 |
|
||||
| `docs_developer` | Earth、前端、后端、采集器和 AI Provider 开发文档 |
|
||||
| `docs_developer` | 智能星球、前端、后端、采集器和 AI Provider 开发文档 |
|
||||
| `docs_admin` | 服务控制、运维、环境变量和敏感操作文档 |
|
||||
|
||||
继承规则:
|
||||
@@ -24,7 +24,7 @@ Docs 使用两层权限:
|
||||
- 未登录用户只能读 `public`。
|
||||
- `docs_developer` 隐含 `docs_user`。
|
||||
- `docs_admin` 隐含 `docs_developer` 和 `docs_user`。
|
||||
- `admin` 和 `super_admin` 默认拥有全部 Docs 权限。
|
||||
- `admin` 和 `super_admin` 默认拥有全部文档权限。
|
||||
|
||||
## 后端入口
|
||||
|
||||
@@ -61,8 +61,8 @@ DocsMetadata(
|
||||
"public",
|
||||
"Manual",
|
||||
2,
|
||||
"Planet 使用手册",
|
||||
"Planet Manual",
|
||||
"智能星球使用手册",
|
||||
"Intelligent Planet Manual",
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Earth 前端结构
|
||||
# 智能星球前端结构
|
||||
|
||||
本文件描述当前 Earth 大屏前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。
|
||||
本文件描述当前智能星球前端的真实结构,重点是帮助后续继续改 HUD、图层、媒体面板、真实地形、BGP 可视化时,不再重复踩结构和状态同步上的坑。
|
||||
|
||||
相关规则建议一起参考:
|
||||
|
||||
@@ -138,7 +138,7 @@ Browser Camera provider 的手势识别管线在 [motion-browser-provider.js](/h
|
||||
- terrain tile 拉取、解码、位移、着色
|
||||
- 海陆基座与国界底图的整球 overlay
|
||||
|
||||
Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座球、高清材质 overlay、云层/大气,以及 `country-boundaries.js` 的海陆基座都需要明确半径间距。远距视图下 GPU 深度精度会下降,相邻 shell 过近会 z-fighting,表现为黑色闪烁块或雪花。当前稳定策略是让海陆基座使用 `landAltitudeOffset = 0.32`,高清材质使用 `textureOverlayAltitudeOffset = 0.48`;后续新增或调整整球地表 overlay 时,必须同步检查 [Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md),并在 50% 缩放视图验证。
|
||||
智能星球地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座球、高清材质 overlay、云层/大气,以及 `country-boundaries.js` 的海陆基座都需要明确半径间距。远距视图下 GPU 深度精度会下降,相邻 shell 过近会 z-fighting,表现为黑色闪烁块或雪花。当前稳定策略是让海陆基座使用 `landAltitudeOffset = 0.32`,高清材质使用 `EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`;国界线、coastline、claim 线和国界 hover 线也必须使用同一个高清材质壳半径,避免转动地球时相对高清贴图产生视差漂浮感。后续新增或调整整球地表 overlay 时,必须同步检查 [智能星球渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md),并在 50% 缩放视图验证。
|
||||
|
||||
### 7. 图层模块
|
||||
|
||||
@@ -162,13 +162,13 @@ Earth 地表是多层近似同心球,不是单一 mesh。`earth.js` 的基座
|
||||
|
||||
`tv.js` 管理 `media-panel` 里的直播 / 态势新闻 tab。toolbar 打开或切换 TV/新闻时,会通过 `earth:tv-visibility-change` 和 `earth:tv-tab-change` 回写 Earth 设置:面板可见性仍按 desktop/mobile viewport 存在 `views.<scope>.panelVisibility.media-panel`,当前 tab 存在 `shared.mediaPanelActiveTab`,因此刷新页面后能恢复用户上次打开的直播或新闻状态。`closeTransientMobileOverlays()` 这类临时收起会带 `persist:false`,不会覆盖用户偏好。
|
||||
|
||||
`brand.js` 管理 Earth HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的 Earth 内容页负责保存和重置品牌配置,Earth 前端只消费结果。
|
||||
`brand.js` 管理智能星球 HUD 品牌资源。默认品牌来自静态资源,运行时覆盖值来自 `/api/v1/earth/brand`,上传的图片通过 `/earth-brand-assets/...` 读取。前端必须把 logo/title 图片和文本 fallback 分开处理:图片加载失败时显示文本标题,文本字段为空时使用后端默认值,避免 HUD 品牌区空白。控制台的智能星球内容页负责保存和重置品牌配置,智能星球前端只消费结果。
|
||||
|
||||
`about.js` 管理 Earth 设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值,避免设置页出现空白。Admin 的 Earth 内容页提供“关于”tab,保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`。
|
||||
`about.js` 管理智能星球设置里的“关于”卡片。默认内容仍保留在前端作为兜底,运行时优先读取 `/api/v1/earth/about`。接口失败或字段缺失时必须回退默认值,避免设置页出现空白。控制台的智能星球内容页提供“关于”tab,保存走 `PUT /api/v1/earth/about`,恢复默认走 `DELETE /api/v1/earth/about`。
|
||||
|
||||
`oobe.js` 管理 Earth 首次初始化引导。是否显示 OOBE 必须由 `/api/v1/earth/oobe-status` 的 `ready` 字段决定,不能依赖 `localStorage` 判断系统是否初始化。`localStorage` 只允许记录“本浏览器暂时跳过”的短时状态;如果后端已经认为 `ready: true`,退出登录、清空本地缓存或换浏览器都不应再次弹出 OOBE。桌面端使用深色星空遮罩和毛玻璃启动面板,移动端改为底部 sheet,并尊重 `prefers-reduced-motion`。
|
||||
|
||||
Admin 的 Earth 内容页必须按运行时语义组织这些配置:
|
||||
控制台的智能星球内容页必须按运行时语义组织这些配置:
|
||||
|
||||
- `品牌标识`:品牌预览应使用与 Earth HUD 左上角一致的深色星空背景、尺寸、间距、logo/title 渲染和文本 fallback,而不是普通表单预览。
|
||||
- `关于`:配置 Earth 设置里的 About 卡片,包括 logo、眉标、标题、版本、描述和元信息条目;Earth 运行时从 `/earth/about` 读取,失败时回退默认内容。
|
||||
@@ -381,23 +381,23 @@ asset 图标大小由 `Interactable` 的 `icon.fitSize` 控制。SVG / 图片文
|
||||
|
||||
`Interactable` 不再把图标本体额外抬离业务高度。`altitudeOffset` 就是 marker、hover glow、locked glow 和 picking 共同使用的地表高度;这样船只图标会继续贴着船只轨迹线,不会因为单独抬高显示位置而显得漂浮。后续如果要解决边缘 glow 裁切,应优先考虑 glow 纹理、overlay 尺寸或图层专属特效,而不是把通用图标层整体抬高。
|
||||
|
||||
跨 Interactable 的同坐标避让也在公共层处理。每个 marker 会保留 `icon_base_position` 作为业务原始位置;当多个 Interactable marker 归入同一个经纬度 key 时,公共层会把它们沿地表切平面排成小圈,并刷新已创建的 `THREE.Points` geometry。这样视觉位置和屏幕空间 picking 位置一致,不需要业务层再单独判断“算力中心和 BGP 事件重叠”这类场景。
|
||||
跨 Interactable 的同坐标关系也在公共层记录,但真实位置必须始终以 `icon_base_position` 为准。缩放、避让、聚合和后续 spiderfy 展开都只能改变屏幕表现,不能写回 `marker.position` 或 `THREE.Points` 里的业务锚点;巡航定位、详情卡、搜索定位和 picking 返回对象都必须落回真实经纬度。多个图标归入同一个经纬度 key 时,公共层只写 `icon_avoidance_*` 元数据,供业务层弱化 halo 或显示聚合提示;真正的低缩放聚合应通过独立 cluster glyph / screen layout 层实现,而不是把对象沿地表切平面挪开。
|
||||
|
||||
`Interactable` 的单点显示只由全局地图缩放决定:170% 及以下强制显示小圆点,超过 170% 显示原图标。cluster 判定使用离散 zoom band 推导出的球面邻近半径,而不是当前屏幕投影距离;同一 band 内同一组地理位置不应因为旋转角度或 100% 到 199% 的连续缩放而改变聚合语义,只有跨过 band 边界才允许拆成更小集群。170% 以上会按 band 收紧聚合阈值,轻微擦边直接拆成图标,避免高缩放下仍然到处是圆点。cluster 每帧从当前可见 marker 重新计算,不使用上一帧聚合状态,避免缩放来回后不同地区被粘成一组。cluster 不使用无限连通分量,避免 A 重叠 B、B 重叠 C 一路串成跨区域大组;圆点展示位置使用局部成员中心,但业务坐标仍以成员真实经纬度为准。cluster 圆点大小随包含对象数量增长,数量过多时按稳定地理顺序拆成多个较小圆点;数量默认只在 hover tooltip 中显示。这个过程只设置 `icon_cluster_*` 展示元数据和重建渲染 Points,不改变每个 marker 的真实经纬度。当前默认只对启用同坐标关系记录的图层开启 cluster,船只这类高频动态层继续关闭。
|
||||
|
||||
接口细节、生命周期和接入示例见:
|
||||
|
||||
- [Earth 可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
|
||||
- [智能星球可交互图标接入](/home/ray/dev/linkong/planet/docs/technical/zh/earth-interactable-usage.md)
|
||||
|
||||
### 视角控制反馈
|
||||
|
||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一维护 Earth 缩放状态。滚轮缩放、缩放按钮和触屏双指捏合最终都会更新 `zoomLevel`,并通过 `showZoomStatusCapsule()` 显示当前缩放比例:
|
||||
[controls.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/controls.js) 统一维护智能星球缩放状态,所有入口最终都必须调用 `setZoomLevel()` 写相机距离。不要在其他模块直接写 `camera.position.z`,否则缩放百分比、拖拽灵敏度和 Interactable 聚合阈值会再次分叉。
|
||||
|
||||
```javascript
|
||||
showGestureStatusMessage(`缩放 ${Math.round(zoomLevel * 100)}%`, "info");
|
||||
```
|
||||
滚轮输入分两类处理:传统鼠标滚轮保留 10% 档位和短动画,并用 `wheelZoomTarget` 作为连续滚动的逻辑基准;触控板 / 高精度滚轮按 pixel delta 连续缩放,直接调用 `setZoomLevel()`,不走 10% 档位动画。触控板路径还会过滤反向后的短窗口旧方向小 residual delta,避免惯性尾巴把用户刚反向的缩放又拉回旧方向。
|
||||
|
||||
该提示每 90ms 最多更新一次,显示 760ms 后淡出。它是视角反馈,不是数据加载进度,也不应该写进图层 loading 状态。
|
||||
|
||||
[main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 只负责在双指捏合缩放时调用 `setZoomLevel()` 和 `showZoomStatusCapsule()`。鼠标滚轮与缩放按钮的胶囊提示应继续放在 `controls.js`,避免同一种缩放反馈散落在多个模块。
|
||||
[main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 只负责在双指捏合缩放和动捕缩放时调用 `setZoomLevel()` 和 `showZoomStatusCapsule()`。鼠标滚轮与缩放按钮的胶囊提示应继续放在 `controls.js`,避免同一种缩放反馈散落在多个模块。
|
||||
|
||||
拖拽地球的旋转灵敏度会根据当前缩放连续衰减,而不是按某个缩放阈值分段:
|
||||
|
||||
@@ -457,7 +457,7 @@ Earth 设置面板当前由 [controls.js](/home/ray/dev/linkong/planet/frontend/
|
||||
|
||||
SGP4 传播输出是惯性系位置,不能直接当成 Earth 的经纬度固定坐标使用。`satellites.js` 会用当前时间的 `gstime` 把 ECI/TEME 位置转换到 ECF,再映射到 `latLonToVector3()` 使用的 Three.js 坐标轴。卫星点和短尾迹使用随采样时间变化的地固坐标,表示相对当前地球表面的实际位置;锁定后的预测轨道线使用锁定时刻固定的 `gstime`,把未来一圈惯性轨道投到当前地球姿态上显示,因此会闭合,并且轨道面倾角应与详情卡一致。fallback 预测轨道也必须使用真正的 RAAN + inclination 轨道平面公式,不能把 inclination 当成恒定纬度。
|
||||
|
||||
国界精度偏好独立存储在 `country-boundaries.js` 的 `planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles,也继续加载低精 `countries-admin0.min.geojson` fallback;开启高精但高精产物缺失时,Earth 工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮;如果鼠标同时命中卫星、船只、BGP 等 interactable,tooltip 显示 interactable 信息,但国界高亮不应闪烁。
|
||||
国界精度偏好独立存储在 `country-boundaries.js` 的 `planet.earth.boundaries.highPrecisionEnabled`。未开启高精时,即使本机已经有高精 manifest/PMTiles,也继续加载低精 `countries-admin0.min.geojson` fallback;开启高精但高精产物缺失时,智能星球工具栏设置会调用 `/api/v1/earth/boundaries/build` 启动后台构建并轮询进度。构建成功后调用 `reloadCountryBoundaries()` 热切换,不再刷新整个页面。国界 hover 与 tooltip 解耦:只要地表坐标落在国界 polygon 内就保持高亮;如果鼠标同时命中卫星、船只、BGP 等 interactable,tooltip 显示 interactable 信息,但国界高亮不应闪烁。
|
||||
|
||||
## 当前地形链路
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Earth Interactable 使用说明
|
||||
# 智能星球 Interactable 使用说明
|
||||
|
||||
`Interactable` 是 Earth 地表“图标类可交互元素”的通用渲染入口。它把船只图层验证过的模式抽成公共能力:普通态用批量 `THREE.Points`,hover / locked 用少量 overlay,拾取走屏幕空间命中,图标资源统一转进 canvas texture,并在公共层处理 glow、状态、尺寸、贴地渲染和同坐标避让。
|
||||
`Interactable` 是智能星球地表“图标类可交互元素”的通用渲染入口。它把船只图层验证过的模式抽成公共能力:普通态用批量 `THREE.Points`,hover / locked 用少量 overlay,拾取走屏幕空间命中,图标资源统一转进 canvas texture,并在公共层处理 glow、状态、尺寸、贴地渲染和同坐标避让。
|
||||
|
||||
当前已接入:
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Earth 图层样式属性索引
|
||||
# 智能星球图层样式属性索引
|
||||
|
||||
本文记录当前 Earth 前端各图层的材质、颜色、透明度、线宽、半径偏移和
|
||||
`renderOrder` 等样式属性。层级关系请配合
|
||||
[Earth 渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)
|
||||
[智能星球渲染图层顺序](/home/ray/dev/linkong/planet/docs/technical/zh/earth-render-layer-order.md)
|
||||
查看。
|
||||
|
||||
## 命名约定
|
||||
@@ -20,12 +20,12 @@
|
||||
|
||||
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Earth 基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| Earth 基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| Earth 基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| Earth 基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| Earth 基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| Earth 基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 智能星球基座半径 | `CONFIG.earthRadius` | `100` | `earth.js:createEarth()` |
|
||||
| 智能星球基座颜色 | `EARTH_MATERIAL_CONFIG.color` | `0x010609` | `MeshPhongMaterial.color` |
|
||||
| 智能星球基座 emissive | `EARTH_MATERIAL_CONFIG.emissive` | `0x010609` | `MeshPhongMaterial.emissive` |
|
||||
| 智能星球基座 specular | `EARTH_MATERIAL_CONFIG.specular` | `0x1a2d45` | `MeshPhongMaterial.specular` |
|
||||
| 智能星球基座 shininess | `EARTH_MATERIAL_CONFIG.shininess` | `12` | `MeshPhongMaterial.shininess` |
|
||||
| 智能星球基座 opacity | `EARTH_MATERIAL_CONFIG.opacity` | `1` | `MeshPhongMaterial.opacity` |
|
||||
| 高清材质半径偏移 | `EARTH_MATERIAL_CONFIG.textureOverlayAltitudeOffset` | `0.48` | 独立高清材质球半径;必须与海陆基座和地球基座保持足够深度间距,避免远距 z-fighting |
|
||||
| 高清材质透明度 | `EARTH_MATERIAL_CONFIG.textureOverlayOpacity` | `0.88` | 高清材质 `MeshPhongMaterial.opacity` |
|
||||
| 高清材质 renderOrder | `EARTH_MATERIAL_CONFIG.textureOverlayRenderOrder` | `0.96` | `_earthTextureOverlay.renderOrder` |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Earth 渲染图层顺序
|
||||
# 智能星球渲染图层顺序
|
||||
|
||||
本文记录当前 Earth 渲染器的图层顺序和每层意图。后续调整
|
||||
`renderOrder`、半径偏移、深度策略或指针交互时,需要同步更新这里。
|
||||
@@ -14,19 +14,19 @@
|
||||
|
||||
| 顺序 | 图层 | 来源 | 渲染 / 半径策略 | 深度 / 交互策略 | 备注 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有 Earth 内容之后。 |
|
||||
| -1 | Earth 遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | Earth 基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| -1000 | 天体背景 mesh | `celestial.js` | 背景球 | 不参与地表拾取 | 位于所有智能星球内容之后。 |
|
||||
| -1 | 智能星球遮挡球 | `earth.js` | 地球内侧不可见球 | 写入深度 | 遮挡地球背面的对象。 |
|
||||
| 0 | 智能星球基座球 | `earth.js` | `CONFIG.earthRadius` | 地表拾取兜底目标 | 深色基座,所有可选地图层关闭时仍可见。 |
|
||||
| 0.2 | 国界暗色 tint | `country-boundaries.js` | `tintAltitudeOffset` | 禁用 raycast | 高清材质关闭时使用。 |
|
||||
| 0.86 | 海陆基座填充 | `country-boundaries.js` | `landAltitudeOffset = 0.32`; 海洋 `#010609`,陆地 `#080f1b` | 禁用 raycast | 即使国界线关闭,基座地图仍保持可用;半径与基座球拉开以避免远距 z-fighting。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
|
||||
| 0.96 | 高清 Earth 材质 | `earth.js` | `textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | 可见时作为地表拾取目标 | 高清材质始终压过海陆基座填充;半径必须高于海陆基座并与基座球保持足够间距。 |
|
||||
| 1 | 大气辉光和云图 | `earth.js` | 大气 / 云层球 | 不走普通对象选择路径 | 云图由“大气云图”图层开关控制。 |
|
||||
| 1 | 海缆 / 登陆点 | `cables.js` | 海缆线和登陆点都使用 `renderOrder = 1`;半径偏移都为 `0.2`;登陆点是专用 `THREE.Sprite` 黄色扁平球 | 海缆走海缆拾取路径;登陆点 `depthTest: false` 保持球体完整,并用相机到球心的球体遮挡判断避免背面穿透 | 登陆点和海缆同层贴地,避免地表设施层的凌空感。 |
|
||||
| 1.2 | 真实地形 | `earth.js`, `terrain.js` | `TERRAIN_CONFIG.baseRadiusOffset` 加地形位移 | 禁用 raycast | 地形压过高清材质;高清材质关闭时临时隐藏,重新开启后恢复原状态。 |
|
||||
| 2.05 | 经纬线 | `earth.js` | `CONFIG.earthRadius + 0.14` | 禁用 raycast | 低透明度显示在高清材质上。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = 0.115` | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制;地形 `depthWrite: false`,所以地形开启时仍可见。 |
|
||||
| 2.2 | 国界线 | `country-boundaries.js` | `lineAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`;claim 线不再额外抬高 | `depthTest: true`,禁用 raycast | 线层使用独立 line geometry 与 `renderOrder` 控制,但半径与高清材质壳完全一致,避免转动地球时与高清贴图出现视差。 |
|
||||
| 2.29 | 国界 hover 光晕 | `country-boundaries.js` | hover 半径加 glow 偏移 | `depthTest: false`,禁用 raycast | 用 additive 光晕增强交界边和地形开启时的 hover 可见性。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = 0.115` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;与普通国界线同源半径对齐,避免重影;中国和中国(台湾)共享高亮组。 |
|
||||
| 2.3 | 国界 hover 实线 | `country-boundaries.js` | `hoverAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48` | `depthTest: false`,禁用 raycast | 霓虹红橘 hover 线;与普通国界线和高清材质同源半径对齐,避免重影和漂浮感;中国和中国(台湾)共享高亮组。 |
|
||||
| 3 | 卫星 footprint 填充 / Iridium coverage ring | `satellites.js`, `iridium-footprint-adapter.js` | `GROUND_FOOTPRINT_RENDER_ORDER` | depth-tested;Iridium adapter 的 fill / ring 也使用同一 renderOrder | Footprint 在 land / texture / terrain 和国界线之上,但在算力中心和卫星之下。 |
|
||||
| 3-4.5 | BGP 观测站、事件扩散圈和事件 marker | `bgp.js`, `interactable.js` | BGP 观测站和事件 marker 均使用 `Interactable` 批量 `THREE.Points`;事件 marker 使用 `BGP_EVENT_RENDER_ORDER = 4.5`;观测站主图标使用 `BGP_COLLECTOR_RENDER_ORDER = 4.4` 和 `BGP_CONFIG.collectorAltitudeOffset = 0.2`;事件 overlay 进入 `bgp-event-overlay-layer`;观测站 halo 和覆盖扇形进入 `bgp-collector-radar-layer` | BGP 事件和观测站都通过 `Interactable` 屏幕空间 picking,并参与同坐标避让 | BGP 观测站主图标与船只同层;BGP 事件与算力中心同层;向外扩散圈、观测站雷达/覆盖动画继续由 BGP 业务逻辑驱动。 |
|
||||
| 4.3 | AIS 船只轨迹线 | `vessels.js` | `VESSEL_RENDER_ORDER - 0.1`;`CONFIG.earthRadius + VESSEL_CONFIG.track.altitudeOffset` | 跟随船只显隐,不单独参与拾取 | 选中船只后显示最近轨迹,低于船只 marker。 |
|
||||
@@ -55,7 +55,8 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
|
||||
维护规则:
|
||||
|
||||
- 不要用“远距隐藏图层”作为第一反应;先检查相邻 shell 的 `altitudeOffset`、`renderOrder`、`depthTest` 和 `depthWrite`。
|
||||
- 海陆基座和高清材质这类整球 overlay 必须与 `CONFIG.earthRadius` 保持明确间距;当前稳定值为 `landAltitudeOffset = 0.32`、`textureOverlayAltitudeOffset = 0.48`。
|
||||
- 海陆基座和高清材质这类整球 overlay 必须与 `CONFIG.earthRadius` 保持明确间距;当前稳定值为 `landAltitudeOffset = 0.32`、`textureOverlayAltitudeOffset = EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48`。
|
||||
- 国界线、coastline、claim 线和国界 hover 线必须使用 `EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET`,与高清材质壳完全同半径;不要再用低于或高于高清材质的独立线层 offset,否则转动地球时会产生相对地表的视差漂浮感。
|
||||
- 新增整球或近整球地表 overlay 时,必须在 50% 缩放和常用近距视图各截一次图,确认没有黑块、雪花、闪烁,也没有明显漂浮感。
|
||||
- 如果必须调整这些半径,需同步更新本文和 `frontend/public/earth/js/constants.js` 的注释/常量意图。
|
||||
|
||||
@@ -63,7 +64,7 @@ Earth 的地表不是单一 mesh,而是多层近似同心球:基座球、海
|
||||
|
||||
| 交互 | 当前规则 |
|
||||
| --- | --- |
|
||||
| Earth 坐标 hover | 高清材质可见时使用高清材质 overlay 作为地表拾取目标,否则使用 Earth 基座球。 |
|
||||
| 智能星球坐标 hover | 高清材质可见时使用高清材质 overlay 作为地表拾取目标,否则使用智能星球基座球。 |
|
||||
| 国界 hover | 先把地表拾取坐标转成经纬度,再用 GeoJSON 点面判断;国界 hover 线本身不接收 raycast。 |
|
||||
| 国界 hover 视觉 | hover 时压暗普通国界线,并绘制无深度测试的光晕和实线。 |
|
||||
| 中国 / 台湾 hover | `CHN` 和 `TWN` 被归到同一个 hover 高亮组;tooltip 仍显示鼠标实际命中的 feature。 |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Earth 卫星覆盖策略
|
||||
# 智能星球卫星覆盖策略
|
||||
|
||||
本文件记录 Earth 卫星图层当前关于 `footprint` 的产品边界、资料依据和已落地实现,目标是避免把 Starlink 这套专用地表覆盖模型误用到其它星座上。
|
||||
|
||||
相关上下文:
|
||||
|
||||
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
|
||||
- [智能星球前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
|
||||
- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
|
||||
- [backend/app/services/collectors/celestrak.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/celestrak.py)
|
||||
- [frontend/public/earth/js/satellites.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/satellites.js)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Earth 工具栏与浮层协同
|
||||
# 智能星球工具栏与浮层协同
|
||||
|
||||
本文件描述 Earth 大屏右侧工具栏按钮,以及搜索面板、设置弹窗、新闻直播面板、图层面板这几个浮层之间当前的协同规则。改交互、加按钮、调整面板时按这个表对齐,避免出现「点 A 把不该关的 B 也关了」之类的协同冲突。
|
||||
|
||||
相关入口:
|
||||
|
||||
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
|
||||
- [智能星球前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
|
||||
- [前端布局指南](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-layout-guidelines.md)
|
||||
|
||||
## 工具栏按钮目录
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 常见问题
|
||||
|
||||
这页集中收录本地启动、Windows / WSL、依赖、动捕、凭证和 Docs 权限相关的常见排障路径。更完整的背景说明仍在对应专题文档中,这里只保留最常用的判断顺序和命令。
|
||||
这页集中收录本地启动、Windows / WSL、依赖、动捕、凭证和文档权限相关的常见排障路径。更完整的背景说明仍在对应专题文档中,这里只保留最常用的判断顺序和命令。
|
||||
|
||||
## 启动与端口
|
||||
|
||||
@@ -114,7 +114,7 @@ curl http://localhost:8010/health
|
||||
|
||||
如果 `ss -ltnp` 显示前端已经监听 `0.0.0.0:3000`,但 Windows PowerShell 中 `Test-NetConnection <Windows局域网IP> -Port 3000` 仍失败,问题通常不在 Vite 或 `.zshrc`,而是在 Windows 侧端口占用、旧 `portproxy` 或防火墙。
|
||||
|
||||
`./planet.sh start --allow-lan` 会直接开放 `3000` / `8000` / `8010`,并在启动前检测端口、旧 `portproxy` 和 Windows 防火墙规则。端口被 Windows 侧 listener 占用时,脚本会请求管理员 PowerShell 清理;缺少入站放行时,也会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理:
|
||||
`./planet.sh start --allow-lan` 会直接开放 `3000` / `8000` / `8010`,并在启动前检测端口、旧 `portproxy` 和 Windows 防火墙规则。端口被 Windows 侧 listener 占用时,脚本会请求管理员 PowerShell 清理;缺少入站允许规则时,也会触发一次 UAC 管理员 PowerShell 请求来自动创建。若自动请求被取消,可以手动清理:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
@@ -128,7 +128,7 @@ New-NetFirewallRule -DisplayName "WSL Planet 8010" -Direction Inbound -Action Al
|
||||
|
||||
局域网设备访问 Windows 对外端口,例如 `http://<Windows局域网IP>:3000/earth`。
|
||||
|
||||
如果 `wslinfo --networking-mode` 输出 `mirrored`,还需要检查 Hyper-V firewall。普通 Windows 防火墙规则存在时,Hyper-V firewall 仍可能拦截外部设备进入 WSL。管理员 PowerShell 中按端口放行:
|
||||
如果 `wslinfo --networking-mode` 输出 `mirrored`,还需要检查 Hyper-V firewall。普通 Windows 防火墙规则存在时,Hyper-V firewall 仍可能拦截外部设备进入 WSL。管理员 PowerShell 中按端口开放访问:
|
||||
|
||||
```powershell
|
||||
New-NetFirewallHyperVRule -Name "Planet-Frontend-3000" -DisplayName "Planet Frontend 3000" -Direction Inbound -VMCreatorId "{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}" -Protocol TCP -LocalPorts 3000 -Action Allow
|
||||
@@ -321,7 +321,7 @@ Docs 按 Gatekeeper 权限组控制可见性:
|
||||
- 快速开始、使用手册、FAQ 等基础文档公开可见。
|
||||
- 开发文档通常需要 `docs_developer`。
|
||||
- 运维和服务控制文档通常需要 `docs_admin`。
|
||||
- `admin` 和 `super_admin` 默认具备 Docs 权限;普通用户需要在控制台“用户管理”中分配权限组。
|
||||
- `admin` 和 `super_admin` 默认具备文档权限;普通用户需要在控制台“用户管理”中分配权限组。
|
||||
|
||||
## Earth 常见操作
|
||||
|
||||
@@ -332,9 +332,9 @@ Docs 按 Gatekeeper 权限组控制可见性:
|
||||
要启用高精国界,有两条入口:
|
||||
|
||||
- Earth 页面齿轮设置里的“国界精度”:切到“高精”会启动首次后台下载/构建,并显示百分比,完成后自动应用。
|
||||
- 控制台 `运维与配置 -> Earth 内容 -> 国界精度`:适合查看 provider、manifest、PMTiles、fallback 状态,编辑源配置 JSON,或手动重建。
|
||||
- 控制台 `运维与配置 -> 智能星球内容 -> 国界精度`:适合查看 provider、manifest、PMTiles、fallback 状态,编辑源配置 JSON,或手动重建。
|
||||
|
||||
如果看到“更新源未配置完整”,先到 `Earth 内容 -> 国界精度` 保存源配置;本机私有配置写入 `config/earth-boundary-sources.local.json`,不要提交到仓库。没有高精产物时,使用低精 fallback 是正常行为。
|
||||
如果看到“更新源未配置完整”,先到 `智能星球内容 -> 国界精度` 保存源配置;本机私有配置写入 `config/earth-boundary-sources.local.json`,不要提交到仓库。没有高精产物时,使用低精 fallback 是正常行为。
|
||||
|
||||
### Earth 位置候选采集后没有写入怎么办?
|
||||
|
||||
@@ -342,9 +342,9 @@ Docs 按 Gatekeeper 权限组控制可见性:
|
||||
|
||||
算力中心候选保存后会写入 `compute_center_locations`。没有可用候选的记录会保留在待定位列表中,系统不会用国家中心点或硬编码 hint 伪造位置。
|
||||
|
||||
### Earth 品牌 logo 或标题改完后为什么没恢复默认?
|
||||
### 智能星球品牌 logo 或标题改完后为什么没恢复默认?
|
||||
|
||||
Earth 品牌资源在控制台 `运维与配置 -> Earth 内容 -> 品牌资源` 中维护。上传图片后页面会使用返回的 Earth 品牌资产地址;如果只是清空标题、ARIA 文案等文本字段,系统会回退到默认标题,避免出现空白品牌。
|
||||
智能星球品牌资源在控制台 `运维与配置 -> 智能星球内容 -> 品牌资源` 中维护。上传图片后页面会使用返回的智能星球品牌资产地址;如果只是清空标题、ARIA 文案等文本字段,系统会回退到默认标题,避免出现空白品牌。
|
||||
|
||||
要恢复发布包自带的默认 logo、标题图和文案,使用“重置品牌资源”。只刷新 Earth 页面不会删除已经保存的运行时品牌配置。
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
- [App.tsx](/home/ray/dev/linkong/planet/frontend/src/App.tsx)
|
||||
|
||||
当前正式后台路由已经由 Admin 接管:
|
||||
当前正式后台路由已经由控制台接管:
|
||||
|
||||
- `/admin`
|
||||
- `/users`
|
||||
@@ -37,7 +37,7 @@
|
||||
- `/collection-management`
|
||||
- `/settings`
|
||||
|
||||
这些路径渲染 [AdminRoutes.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/AdminRoutes.tsx),页面清单和菜单元信息来自 [manifest.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/routes/manifest.tsx)。Admin 是唯一后台控制台入口,不再维护并行控制台或回退路由。
|
||||
这些路径渲染 [AdminRoutes.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/AdminRoutes.tsx),页面清单和菜单元信息来自 [manifest.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/routes/manifest.tsx)。控制台是唯一后台入口,不再维护并行控制台或回退路由。
|
||||
|
||||
`/earth` 是独立展示页,不属于控制台骨架。
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
- 当前账号、版本、退出登录和主题切换
|
||||
- 顶部搜索、面包屑和页面快捷入口
|
||||
- 内容区单屏高度闭合
|
||||
- Admin 内部滚动、表格、详情面板和移动端详情视图协调
|
||||
- 控制台内部滚动、表格、详情面板和移动端详情视图协调
|
||||
|
||||
后续正式控制台页面应适配 `AdminLayout` 和 Admin 页面模式;不要重新引入并行后台壳层。
|
||||
后续正式控制台页面应适配 `AdminLayout` 和控制台页面模式;不要重新引入并行后台壳层。
|
||||
|
||||
## Admin 分区加载策略
|
||||
## 控制台分区加载策略
|
||||
|
||||
多 tab 页面由 [PlainResourcePages.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 统一承载当前的管理型和信息型工作台。分区加载规则是:
|
||||
|
||||
@@ -84,7 +84,7 @@ Admin 的数据源页把单源触发、表格勾选触发和触发全部统一
|
||||
|
||||
这个队列是用户感知层,不替代后端调度状态。后端仍然是任务是否运行、完成、失败或跳过的唯一事实来源。
|
||||
|
||||
## Admin 主题滑块
|
||||
## 控制台主题滑块
|
||||
|
||||
Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ray/dev/linkong/planet/frontend/src/components/SegmentedControl/SegmentedControl.tsx),但主题变量在 [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin/styles.css) 内跟随 `data-theme` 覆盖:
|
||||
|
||||
@@ -92,6 +92,31 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
- dark 下使用与 Docs 一致的深色底座、`#202938` slider 和深色外投影。
|
||||
- 业务侧只隐藏文字 label 并保留 icon + tooltip,不重写 slider DOM。
|
||||
|
||||
## 控制台状态颜色
|
||||
|
||||
Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/frontend/src/admin/patterns/patterns.tsx) 或 [Badge](/home/ray/dev/linkong/planet/frontend/src/admin/components/ui/badge.tsx),颜色变量来自 [styles.css](/home/ray/dev/linkong/planet/frontend/src/admin/styles.css)。新增状态时不要在页面里临时写 hex;先判断语义,再映射到现有 tone。
|
||||
|
||||
`StatusText` 是带圆点的指示灯:胶囊背景和边框保持组件原色,只让圆点和文字变成状态色。`Badge` 不带指示灯语义,可以使用同 tone 的浅色背景和边框强化信息层级。
|
||||
|
||||
| Tone | 颜色变量 | 语义 | 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| `success` | `--an-success` | 可用、成功、已连接、已启用 | 日志源 `可用`、采集 `成功` |
|
||||
| `warning` | `--an-warning` | 需要关注但不一定失败 | 日志文件 `暂无日志`、降级或跳过 |
|
||||
| `danger` | `--an-danger` | 失败、不可用、权限/连接错误 | `Docker 不可用`、接口失败 |
|
||||
| `info` / `running` | `--an-info` | 进行中、同步中、普通信息态 | `跟随中`、`同步中` |
|
||||
| `neutral` | `--an-muted` | 空态、停用、未上报、未知 | `暂无上报`、`停用` |
|
||||
| `ai` | 固定紫色 | AI 相关突出态 | AI 生成、模型动作 |
|
||||
|
||||
日志源列表遵循同一规则:`ok` 显示 success;`empty` 表示来源存在但还没有上报,显示 neutral;`missing` 表示期望的日志文件暂时不存在,显示 warning;`docker_unavailable` / `source_unavailable` 显示 danger。
|
||||
|
||||
## 控制台运行时日志
|
||||
|
||||
控制台运行时错误由 [runtimeLogs.ts](/home/ray/dev/linkong/planet/frontend/src/admin/runtimeLogs.ts) 统一上报到 `/api/v1/system/logs/admin-client`。它只在控制台路由内启用,跳过 `/earth`、`/docs`、登录和注册页面,避免公开页面噪声进入控制台日志源。
|
||||
|
||||
[AdminErrorBoundary.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/components/AdminErrorBoundary.tsx) 捕获 React 渲染错误并复用同一上报函数;全局 `error` 和 `unhandledrejection` 也进入该通道。上报失败必须静默处理,不能因为日志系统异常再制造新的前端错误。
|
||||
|
||||
日志页通过 `/ws` 的 `logs_tail` channel 跟随日志增量;文件日志和数据库日志都由后端统一转换成行事件。新增日志源时优先接入后端 source registry 和 tail manager,不要在日志页写独立轮询器。
|
||||
|
||||
## 当前共享组件
|
||||
|
||||
### 1. `Scrollbar`
|
||||
@@ -125,7 +150,7 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
|
||||
当前使用场景:
|
||||
|
||||
- Admin 数据源、采集数据、采集管理、日志、告警和 BGP 页面
|
||||
- 控制台数据源、采集数据、采集管理、日志、告警和 BGP 页面
|
||||
|
||||
### 3. `TableScrollRegion`
|
||||
|
||||
@@ -148,10 +173,10 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
|
||||
用途:
|
||||
|
||||
- Admin 全局工具按钮和详情页工具按钮
|
||||
- 控制台全局工具按钮和详情页工具按钮
|
||||
- icon-only + tooltip 的普通操作
|
||||
- 保存、创建、确认、删除、停止等强意图操作
|
||||
- 与 Docs 主题滑块一致的紧凑开关
|
||||
- 与文档主题滑块一致的紧凑开关
|
||||
|
||||
当前约束:
|
||||
|
||||
@@ -198,7 +223,7 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
- 渲染 `/docs` 的 Markdown 正文
|
||||
- 支持标题、列表、引用、代码块、表格和基础行内格式
|
||||
- 代码块和表格内部复用 `Scrollbar`,避免横向内容撑爆文档页
|
||||
- Docs 正文由后端 `/api/v1/docs/...` 按 Gatekeeper 权限返回;前端只渲染当前用户可见内容
|
||||
- 文档正文由后端 `/api/v1/docs/...` 按 Gatekeeper 权限返回;前端只渲染当前用户可见内容
|
||||
|
||||
当前约束:
|
||||
|
||||
@@ -206,7 +231,7 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
- 文档内部链接应通过 `transformLink` 转成 `/docs/:slug`
|
||||
- 标题锚点由 `getHeadingId` 注入,避免渲染器自己理解路由状态
|
||||
|
||||
### 7. Admin UI primitives
|
||||
### 7. 控制台 UI primitives
|
||||
|
||||
文件:
|
||||
|
||||
@@ -217,7 +242,7 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
用途:
|
||||
|
||||
- 全局工具按钮、详情页动作、确认弹窗和二元设置。
|
||||
- 与 Tactile UI token 对齐,保持 Admin 内部控件尺寸、hover、disabled 和 dark mode 一致。
|
||||
- 与 Tactile UI token 对齐,保持控制台内部控件尺寸、hover、disabled 和 dark mode 一致。
|
||||
- 表格行内动作优先使用 icon button + tooltip/title,不重新引入独立操作菜单组件。
|
||||
|
||||
## 当前状态来源
|
||||
@@ -326,9 +351,9 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
|
||||
### 采集器设置页
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是 Earth 内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是智能星球内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。
|
||||
|
||||
`/settings` 的“系统显示”分区包含 `演示模式` 开关。开启后,Earth 的 OOBE 会忽略“已有当前采集数据”和本地“先浏览”临时跳过状态,直接展示初始化引导;该开关仅用于演示/验收流程,不改变数据源、采集队列或 Earth 内容资源配置。
|
||||
`/settings` 的“系统显示”分区包含 `演示模式` 开关。开启后,智能星球的 OOBE 会忽略“已有当前采集数据”和本地“先浏览”临时跳过状态,直接展示初始化引导;该开关仅用于演示/验收流程,不改变数据源、采集队列或智能星球内容资源配置。
|
||||
|
||||
当前页面边界:
|
||||
|
||||
@@ -359,7 +384,7 @@ Admin 侧栏底部主题切换继续复用共享 [SegmentedControl.tsx](/home/ra
|
||||
|
||||
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
|
||||
|
||||
### Earth 内容页
|
||||
### 智能星球内容页
|
||||
|
||||
`/earth-content` 复用 [Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 的单屏 tab 容器,但页面责任与系统设置分离:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`backend/app/services/location/` 是所有“给定一条记录,决定它的 lat/lon”业务的共享抽象。算力中心、BGP 观测站、BGP 事件目前都跑在这条管线上。未来需要位置估算的实体,例如卫星地面站、用户认领点位、IXP 设施,也应接入这里,而不是各自再写地理解析逻辑。
|
||||
|
||||
用户侧流程见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Earth 位置候选采集章节。
|
||||
用户侧流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Earth 位置候选采集章节。
|
||||
|
||||
## 设计目标
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Planet 使用手册
|
||||
# 智能星球使用手册
|
||||
|
||||
这份手册面向 Planet 的最终用户。从打开浏览器开始,覆盖注册账号、登录、配置数据采集器、配置 AI、使用 Earth 和控制台、阅读文档站。所有操作都在浏览器里完成。
|
||||
这份手册面向智能星球的最终用户。从打开浏览器开始,覆盖注册账号、登录、配置数据采集器、配置 AI、使用智能星球和控制台、阅读文档站。所有操作都在浏览器里完成。
|
||||
|
||||
如果你是负责部署或值班的运维,请改读 [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md),里面是 shell 命令、日志位置、SMTP 兜底创建用户等内容。
|
||||
如果你是负责部署或值班的运维,请改读 [智能星球运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md),里面是 shell 命令、日志位置、SMTP 兜底创建用户等内容。
|
||||
|
||||
## 入口总览
|
||||
|
||||
| 名称 | 地址 | 是否需要登录 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| Earth | `http://<域名>/earth` | 否 | 公开 3D 地球态势页面 |
|
||||
| Docs | `http://<域名>/docs` | 部分需要 | 公共文档免登录,开发/运维文档按 Gatekeeper 权限组开放 |
|
||||
| 智能星球 | `http://<域名>/earth` | 否 | 公开 3D 态势页面 |
|
||||
| 文档 | `http://<域名>/docs` | 部分需要 | 公共文档免登录,开发/运维文档按 Gatekeeper 权限组开放 |
|
||||
| 注册 / 登录 / 找回密码 | `/register`、`/login`、`/forgot-password` | 否 | 自助开通和恢复账号 |
|
||||
| 控制台 | `http://<域名>/admin` | 是 | 数据、采集器、告警、AI、用户、设置 |
|
||||
| AI | `http://<域名>/ai` | 是 | 模型供应商、工具和测试台 |
|
||||
@@ -68,7 +68,7 @@
|
||||
| 页面 | 路由 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 仪表盘 | `/admin` | 系统概览 |
|
||||
| Earth | `/earth` | 跳到公开 Earth 页面 |
|
||||
| 智能星球 | `/earth` | 跳到公开智能星球页面 |
|
||||
| 数据源 | `/datasources` | 数据源目录、触发采集 |
|
||||
| 采集数据 | `/data` | 已落库的数据 |
|
||||
| BGP 观测 | `/bgp` | BGP 专题观测 |
|
||||
@@ -76,7 +76,7 @@
|
||||
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
|
||||
| 态势告警 | `/alerts/situational` | 态势研判告警 |
|
||||
| AI | `/ai` | 模型供应商、工具、测试台 |
|
||||
| Earth 内容 | `/earth-content` | 电视直播、国界精度、底图和图层资源入口 |
|
||||
| 智能星球内容 | `/earth-content` | 电视直播、国界精度、底图和图层资源入口 |
|
||||
| 采集管理 | `/collection-management` | 采集器、采集调度、采集历史入口 |
|
||||
| 系统日志 | `/logs` | 通常仅 super admin 可见 |
|
||||
| 用户管理 | `/users` | 创建/删除/改角色/调权限组 |
|
||||
@@ -186,19 +186,19 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
电视直播和国界精度已经移到 `/earth-content`,采集器和采集调度已经移到 `/collection-management`,AI Provider / WebSearch / OCR 在 `/ai`。
|
||||
|
||||
### Earth 内容
|
||||
### 智能星球内容
|
||||
|
||||
`/earth-content` 位于控制台“运维与配置”下,面向 Earth 前端体验资源:
|
||||
`/earth-content` 位于控制台“运维与配置”下,面向智能星球前端体验资源:
|
||||
|
||||
- **品牌资源**:维护 Earth HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为 Earth 品牌资产并立即供 Earth 页面读取。
|
||||
- **关于**:维护 Earth 设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
|
||||
- **电视直播**:维护 Earth 媒体面板里的直播源。
|
||||
- **品牌资源**:维护智能星球 HUD 使用的 logo、标题图、标题文本、副标题和描述;上传的图片会保存为智能星球品牌资产并立即供智能星球页面读取。
|
||||
- **关于**:维护智能星球设置面板里的关于卡片,包括 logo、眉标、标题、版本、描述和元信息。
|
||||
- **电视直播**:维护智能星球媒体面板里的直播源。
|
||||
- **国界精度**:查看当前国界 provider、低精 fallback、高精 PMTiles/manifest 状态,编辑本机源配置并手动构建。
|
||||
- **地球底图**、**图层资源**、**三维素材**、**新闻锚点策略**:目前是待接入占位页,不展示假数据。
|
||||
|
||||
Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
|
||||
智能星球页面工具栏齿轮中也有“国界精度”。切到“高精”时,如果本机尚未构建高精资产,会像游戏更新包一样启动后台下载/构建并显示百分比;构建成功后自动应用,无需刷新。切回“低精”只切换本机显示偏好,不重新下载。
|
||||
|
||||
如果后端判断 Earth 尚未初始化,首次进入 `/earth` 会出现毛玻璃引导,提示登录控制台并采集数据。这个判断来自后端真实数据状态;如果系统已经有已采集数据,清空浏览器缓存也不会重新弹出。
|
||||
如果后端判断智能星球尚未初始化,首次进入 `/earth` 会出现毛玻璃引导,提示登录控制台并采集数据。这个判断来自后端真实数据状态;如果系统已经有已采集数据,清空浏览器缓存也不会重新弹出。
|
||||
|
||||
### 采集管理
|
||||
|
||||
@@ -229,7 +229,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
|
||||
- 查看用户列表(用户名、邮箱、角色、是否激活、邮箱是否已验证)
|
||||
- 创建用户:与公开注册等价,但跳过邮箱验证(管理员认账)
|
||||
- 修改角色:`viewer` / `operator` / `admin` / `super_admin`
|
||||
- 调整 Gatekeeper 权限组:`docs_user` / `docs_developer` / `docs_admin`,影响 Docs 站可见文档范围
|
||||
- 调整 Gatekeeper 权限组:`docs_user` / `docs_developer` / `docs_admin`,影响文档站可见文档范围
|
||||
- 禁用 / 启用账号
|
||||
|
||||
要让普通用户能看开发或运维文档,进 `/users` 给他加 `docs_developer` 或 `docs_admin`。
|
||||
@@ -238,7 +238,7 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
|
||||
|
||||
- `/datasources`:数据源目录。`内置源` 支持按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;未勾选时主按钮显示“触发全部”,勾选多行后会变成“触发已选 N”,并只提交所选数据源。右上角队列按钮空态显示队列图标,有任务时显示纯圆环总进度;点击后打开队列浮层,按运行中、完成、失败和跳过分组,失败项可重试,完成项可跳到详情。`实时源` 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。接口、凭证、请求头的编辑统一在 `/collection-management` 的"采集器"。
|
||||
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与智能星球的 BGP 图层互补
|
||||
- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警
|
||||
|
||||
## AI 测试台
|
||||
@@ -251,9 +251,9 @@ Earth 页面工具栏齿轮中也有“国界精度”。切到“高精”时
|
||||
|
||||
旧链接 `/playground` 会跳到这里。
|
||||
|
||||
## Earth 公开页面
|
||||
## 智能星球公开页面
|
||||
|
||||
Earth `http://localhost:3000/earth` 是公开 3D 态势页面,不需要登录。React 路由中的 `/earth` 用 iframe 承载独立前端(位于 `frontend/public/earth/`)。
|
||||
智能星球 `http://localhost:3000/earth` 是公开 3D 态势页面,不需要登录。React 路由中的 `/earth` 用 iframe 承载独立前端(位于 `frontend/public/earth/`)。
|
||||
|
||||
### 主要用途
|
||||
|
||||
@@ -283,14 +283,14 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后用"自动采集坐标候选"或"重新自动采集坐标"按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选;常规来源没有候选时使用当前默认 AI Provider 做 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
|
||||
|
||||
候选可以直接在 Earth 预览。算力中心候选点击"保存"后写入 `compute_center_locations` 维表并刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击查看列表,单条采集候选,或用"一键采用"从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。
|
||||
候选可以直接在智能星球预览。算力中心候选点击"保存"后写入 `compute_center_locations` 维表并刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击查看列表,单条采集候选,或用"一键采用"从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。
|
||||
|
||||
单个对象的推荐流程:
|
||||
|
||||
1. 打开算力中心或 BGP 观测站详情卡。
|
||||
2. 点击"自动采集坐标候选"。
|
||||
3. 等待候选列表返回;有 WebSearch / AI factcheck 依赖的候选会显示采集中状态。
|
||||
4. 在 Earth 上预览候选位置。
|
||||
4. 在智能星球上预览候选位置。
|
||||
5. 确认可用候选后点击"保存";不确定时关闭卡片不会丢失当前任务状态。
|
||||
|
||||
一键定位用于批量处理算力中心待定位队列。它会从列表顶部开始采用最高置信候选;仍没有事实依据的记录会保留在队列中。未开启 WebSearch 时,单个定位和一键定位会置灰,因为位置核验依赖事实查询。
|
||||
@@ -324,12 +324,12 @@ AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军
|
||||
|
||||
### 动作捕捉控制
|
||||
|
||||
Earth 预留了动作捕捉控制入口。实时链路两种输入源:
|
||||
智能星球预留了动作捕捉控制入口。实时链路两种输入源:
|
||||
|
||||
- **浏览器摄像头**(默认):直接用网页 `getUserMedia` 在本机浏览器识别;无需安装应用,但页面必须运行在 HTTPS 或 localhost,且需允许浏览器摄像头权限
|
||||
- **Motion Agent**:摄像头/RTSP/HTTP → 本地 Agent → 本地 WebSocket → Earth 页面;用于双摄、USB index、手机/网络摄像头流
|
||||
- **Motion Agent**:摄像头/RTSP/HTTP → 本地 Agent → 本地 WebSocket → 智能星球页面;用于双摄、USB index、手机/网络摄像头流
|
||||
|
||||
打开方式:设置中开启"动捕调试模式",或加 URL 参数 `?motion=1` 打开 Earth 动捕连接。Motion Agent 默认地址 `ws://127.0.0.1:8765/ws/gestures`,可用 `motionAgent` URL 参数覆盖。也可以直接 `?motion=1&motionProvider=browser` 或 `?motion=1&motionProvider=agent`。
|
||||
打开方式:设置中开启"动捕调试模式",或加 URL 参数 `?motion=1` 打开智能星球动捕连接。Motion Agent 默认地址 `ws://127.0.0.1:8765/ws/gestures`,可用 `motionAgent` URL 参数覆盖。也可以直接 `?motion=1&motionProvider=browser` 或 `?motion=1&motionProvider=agent`。
|
||||
|
||||
两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。
|
||||
|
||||
@@ -347,7 +347,7 @@ Earth 预留了动作捕捉控制入口。实时链路两种输入源:
|
||||
|
||||
### 巡航模式
|
||||
|
||||
巡航模式让 Earth 自动轮播聚焦目标。当前巡航模块:BGP、新闻、算力中心、船只、海缆、卫星。适合演示、监控大屏或无人值守。
|
||||
巡航模式让智能星球自动轮播聚焦目标。当前巡航模块:BGP、新闻、算力中心、船只、海缆、卫星。适合演示、监控大屏或无人值守。
|
||||
|
||||
### 移动端
|
||||
|
||||
@@ -355,27 +355,27 @@ Earth 预留了动作捕捉控制入口。实时链路两种输入源:
|
||||
|
||||
### 常见问题
|
||||
|
||||
- **Earth 打不开**:先确认前端服务是否在线;如果端口不是 `3000`,使用启动输出的实际端口
|
||||
- **智能星球打不开**:先确认前端服务是否在线;如果端口不是 `3000`,使用启动输出的实际端口
|
||||
- **图层没有数据**:进 `/datasources` 看数据源状态、是否已采集和最近执行结果,再到 `/data` 或 `/bgp` 看是否有记录
|
||||
- **卫星 / BGP / 海缆加载慢**:这些图层依赖后端接口和外部数据源,首次加载需要等启动任务完成
|
||||
- **卫星看起来不在同一层**:这是默认的真实高度压缩显示。想回到旧版同层球面,可在设置中关闭“真实卫星高度”
|
||||
|
||||
## Docs 文档站
|
||||
## 文档站
|
||||
|
||||
文档站 `http://localhost:3000/docs` 由后端按权限读取,不再把全部 Markdown 直接打进前端构建产物。
|
||||
|
||||
未登录访客默认只能看到 `public` 文档:首页、快速开始、使用手册、常见问题。登录用户被分配 Gatekeeper 权限组后可以看到更多技术文档:
|
||||
|
||||
- `docs_user`:用户操作类文档
|
||||
- `docs_developer`:Earth、前端、后端、采集器和 AI Provider 等开发文档
|
||||
- `docs_developer`:智能星球、前端、后端、采集器和 AI Provider 等开发文档
|
||||
- `docs_admin`:服务控制、运维、环境变量和敏感操作文档(包括运维手册)
|
||||
|
||||
`admin` 默认拥有 `docs_admin`,`super_admin` 拥有全部 Docs 权限。Gatekeeper 权限组在"用户管理"中配置。
|
||||
`admin` 默认拥有 `docs_admin`,`super_admin` 拥有全部文档权限。Gatekeeper 权限组在"用户管理"中配置。
|
||||
|
||||
Docs 支持分类导航、Markdown 渲染、表格和代码块、文档内目录、对当前可见文档搜索、technical 文档间内部链接跳转。
|
||||
文档站支持分类导航、Markdown 渲染、表格和代码块、文档内目录、对当前可见文档搜索、technical 文档间内部链接跳转。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)
|
||||
- [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)
|
||||
- [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)
|
||||
- [智能星球运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 命名与术语对照
|
||||
|
||||
本文约定 Planet 控制台、Earth、后端 API 和文档中的常见名词。新增 UI 文案、接口字段展示、图表标签和文档说明时,优先使用这里的中文名,避免同一页面中出现不必要的中英混合。
|
||||
本文约定智能星球、控制台、后端 API 和文档中的常见名词。新增 UI 文案、接口字段展示、图表标签和文档说明时,优先使用这里的中文名,避免同一页面中出现不必要的中英混合。
|
||||
|
||||
## 使用规则
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
|
||||
| English / Key | 中文显示名 | 使用场景 |
|
||||
| --- | --- | --- |
|
||||
| Planet | Planet | 产品名,保留英文 |
|
||||
| Planet | 智能星球 | 产品名和主体验入口 |
|
||||
| Admin | 控制台 | 管理端上下文 |
|
||||
| Earth | Earth | 地球可视化产品名,保留英文 |
|
||||
| Earth | 智能星球 | 可视化产品名和 `/earth` 入口 |
|
||||
| datasource | 数据源 | API、列表、筛选 |
|
||||
| collector | 采集器 | 采集任务、凭证配置 |
|
||||
| collected data | 采集数据 | 数据列表、统计 |
|
||||
@@ -56,7 +56,7 @@
|
||||
| `device_stats` | 设备统计 | Cloudflare Radar 设备统计 |
|
||||
| `traffic_stats` | 流量统计 | Cloudflare Radar 流量统计 |
|
||||
| `as_stats` | 自治系统统计 | Cloudflare Radar AS 统计 |
|
||||
| `compute_center` | 算力中心 | Earth 聚合展示对象 |
|
||||
| `compute_center` | 算力中心 | 智能星球聚合展示对象 |
|
||||
| `generic` | 通用数据 | 通用采集输出 |
|
||||
| `generic_records` | 通用记录 | 通用映射输出 |
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ MOTION_AGENT_DRY_RUN=1 PLANET_START_MOTION_AGENT=1 ./planet.sh start
|
||||
./planet.sh start --allow-lan --motion-agent
|
||||
```
|
||||
|
||||
此时 Motion Agent 会绑定 `0.0.0.0`,启动输出会同时显示本机 WebSocket 地址和推荐局域网 WebSocket 地址。局域网浏览器访问 Earth 时,需要把 `motionAgent` 参数指向这台大屏主机,例如:
|
||||
此时 Motion Agent 会绑定 `0.0.0.0`,启动输出会同时显示本机 WebSocket 地址和推荐局域网 WebSocket 地址。局域网浏览器访问智能星球时,需要把 `motionAgent` 参数指向这台大屏主机,例如:
|
||||
|
||||
```text
|
||||
http://<LAN_IP>:3000/earth?motion=1&motionAgent=ws://<LAN_IP>:8765/ws/gestures
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Planet 运维手册
|
||||
# 智能星球运维手册
|
||||
|
||||
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
|
||||
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
|
||||
|
||||
## 首次启动
|
||||
|
||||
@@ -150,7 +150,7 @@ Planet 开发环境建议使用 WSL2。WSL1 下网络、文件系统和进程模
|
||||
wsl --set-version Ubuntu 2
|
||||
```
|
||||
|
||||
`--allow-lan` 会让前端、后端和 AI Provider 直接对开发机开放:前端 `3000`、后端 `8000`、AI Provider `8010`。脚本启动前会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,局域网其他机器访问 Windows 局域网 IP 时还需要 Windows 防火墙放行。
|
||||
`--allow-lan` 会让前端、后端和 AI Provider 直接对开发机开放:前端 `3000`、后端 `8000`、AI Provider `8010`。脚本启动前会检查这三个端口;如果 WSL/Linux 侧无法释放端口,并检测到 Windows 侧 listener 或旧 `portproxy`,会请求管理员 PowerShell 清理。WSL 中运行时,Windows 本机一般可以通过 `localhost` 访问,局域网其他机器访问 Windows 局域网 IP 时还需要 Windows 防火墙允许访问。
|
||||
|
||||
建议按顺序排查:
|
||||
|
||||
@@ -162,7 +162,7 @@ curl http://localhost:8010/health
|
||||
ss -ltnp | grep -E ':3000|:8000|:8010'
|
||||
```
|
||||
|
||||
如果服务已经启动但局域网 IP 仍访问失败,优先清理旧 `portproxy` 并确认 Windows 防火墙放行。脚本会自动检测并请求管理员 PowerShell 处理;自动请求被取消时,手动兜底命令如下:
|
||||
如果服务已经启动但局域网 IP 仍访问失败,优先清理旧 `portproxy` 并确认 Windows 防火墙允许访问。脚本会自动检测并请求管理员 PowerShell 处理;自动请求被取消时,手动兜底命令如下:
|
||||
|
||||
```powershell
|
||||
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
|
||||
@@ -315,11 +315,11 @@ uv run pytest backend/tests/test_otp_service.py
|
||||
|
||||
## Earth 国界 PMTiles 操作步骤
|
||||
|
||||
1. 在控制台 `运维与配置 -> Earth 内容 -> 国界精度` 保存国界源配置;本机配置写入 `config/earth-boundary-sources.local.json`,不要提交。
|
||||
1. 在控制台 `运维与配置 -> 智能星球内容 -> 国界精度` 保存国界源配置;本机配置写入 `config/earth-boundary-sources.local.json`,不要提交。
|
||||
2. 点击“构建高精国界”,或在 Earth 页面工具栏齿轮中切到“高精”触发首次构建。后端会下载三类源到 `data/earth-boundary-sources/`,生成 source manifest,并调用 PMTiles 构建脚本。
|
||||
3. 构建器需要本机 PATH 里有 `tippecanoe` 和 `pmtiles`。缺工具时接口返回明确错误,不会写入数据源采集记录。
|
||||
4. 构建成功后应输出 `frontend/public/earth/data/boundaries/earth-boundaries-china-pov-v1.pmtiles` 和对应 manifest。
|
||||
5. 部署后打开 Earth,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。
|
||||
5. 部署后打开智能星球,开启“国界线”,放大中国东南海岸、台湾、海南、南海、藏南、科索沃、加沙等区域验证 hover 和边界口径。
|
||||
6. 如果本地没有高精 manifest/PMTiles,Earth 会使用 `frontend/public/earth/data/countries-admin0.min.geojson` 低精度 fallback;如果高精产物存在但瓦片请求失败,按 PMTiles range 请求、manifest provider、Nginx `.pmtiles` 静态返回和 sha256 一致性排查。
|
||||
|
||||
## 相关文档
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 业务架构与数据流转
|
||||
|
||||
本文是 Planet 数据产品的业务入口。它解释每类 Earth 数据为什么存在、从哪里采集、落到哪些事实表或派生表、如何通过缓存和 WebSocket 反映到 Earth。前端、后端和 Earth 技术文档只记录实现细节;跨端理解数据链路时优先从这里开始。
|
||||
本文是智能星球数据产品的业务入口。它解释每类 Earth 数据为什么存在、从哪里采集、落到哪些事实表或派生表、如何通过缓存和 WebSocket 反映到 Earth。前端、后端和 Earth 技术文档只记录实现细节;跨端理解数据链路时优先从这里开始。
|
||||
|
||||
## 总览
|
||||
|
||||
Planet 的核心数据链路分三段:
|
||||
智能星球的核心数据链路分三段:
|
||||
|
||||
1. **采集与整理**:内置采集器、后台操作或定位管线写入 PostgreSQL。通用原始结果进入 `collected_data`,图层需要的二次结果进入派生表。
|
||||
2. **投影与广播**:数据库触发器把事实变化写入 `earth_data_change_events` outbox,并用 `LISTEN/NOTIFY` 唤醒后端 listener。listener 通过 layer adapter 找到 Earth 图层,失效缓存并广播 `earth_updates`。
|
||||
@@ -43,7 +43,7 @@ flowchart TB
|
||||
NewsItems --> NewsLayer["news / media 图层"]
|
||||
```
|
||||
|
||||
| 数据产品 | 业务用途 | 事实来源 | 派生 / 维表 | Earth 图层 | 刷新策略 |
|
||||
| 数据产品 | 业务用途 | 事实来源 | 派生 / 维表 | 智能星球图层 | 刷新策略 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 卫星 | 展示在轨目标、轨迹、覆盖和巡航目标 | `celestrak_tle`、`spacetrack_tle` | 无稳定独立派生表,TLE 由接口实时转换 | `satellites` | `clear_then_reload` |
|
||||
| 海缆与登陆点 | 展示跨洋连接、登陆点和 cable 详情 | `arcgis_cables`、`arcgis_landing_points`、TeleGeography / FAO landing sources | 海缆关系和登陆点聚合数据 | `cables` | `clear_then_reload` |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 快速开始
|
||||
|
||||
这份快速开始面向 Planet 的最终用户:你拿到了管理员给的访问地址,要从打开浏览器到第一次完成配置之间的最短路径。所有操作都在浏览器里完成。
|
||||
这份快速开始面向智能星球的最终用户:你拿到了管理员给的访问地址,要从打开浏览器到第一次完成配置之间的最短路径。所有操作都在浏览器里完成。
|
||||
|
||||
如果你是负责部署或运维的同事,请改读 [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)。
|
||||
如果你是负责部署或运维的同事,请改读 [智能星球运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)。
|
||||
|
||||
## 1. 打开访问地址
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
入口分两类:
|
||||
|
||||
- 公开页:`/earth`(3D 态势)、`/docs`(公共文档)
|
||||
- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统设置)、`/earth-content`(Earth 内容)、`/collection-management`(采集管理)
|
||||
- 登录后:`/admin`(控制台)、`/ai`(AI)、`/settings`(系统设置)、`/earth-content`(智能星球内容)、`/collection-management`(采集管理)
|
||||
|
||||
## 2. 注册账号
|
||||
|
||||
@@ -38,17 +38,17 @@
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
|
||||
|
||||
## 4. 打开 Earth
|
||||
## 4. 打开智能星球
|
||||
|
||||
访问 `/earth`,公开页面,不需要登录。
|
||||
|
||||
如果系统还没有已采集数据,Earth 会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
|
||||
如果系统还没有已采集数据,智能星球会显示初始化引导,提示登录控制台并触发采集。这个引导由后端状态决定,不会因为清空浏览器缓存而误判。
|
||||
|
||||
进入后建议确认:
|
||||
|
||||
- 地球正常显示,右侧图层面板可以打开/关闭
|
||||
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
|
||||
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览
|
||||
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在智能星球上预览
|
||||
- 鼠标拖动、滚轮缩放、缩放百分比提示工作正常
|
||||
- 设置面板的旋转 / 巡航 / 动捕模式可以切换;视图设置里可以切换悬停提示,卫星相关设置里可以打开或关闭真实高度分层和轨迹显示
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
|
||||
## 下一步
|
||||
|
||||
- 完整 UI 操作说明:[Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md)
|
||||
- 完整 UI 操作说明:[智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md)
|
||||
- 排障与配置疑问:[常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)
|
||||
- Earth 坐标候选采集流程见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的 Earth 章节
|
||||
- 部署 / 运维相关命令:[Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)
|
||||
- 智能星球坐标候选采集流程见 [智能星球使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md) 的智能星球章节
|
||||
- 部署 / 运维相关命令:[智能星球运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)
|
||||
|
||||
@@ -4,7 +4,7 @@ Tactile UI 是 Planet 内部抽出的可移植 React 控件层。它来自 Admin
|
||||
|
||||
## 设计目标
|
||||
|
||||
- **轻触感**:默认控件使用白色或主题表面、细边框和外部投影,接近 Docs 主题滑块的轻微立体感,不使用大色块或发光效果。
|
||||
- **轻触感**:默认控件使用白色或主题表面、细边框和外部投影,接近文档主题滑块的轻微立体感,不使用大色块或发光效果。
|
||||
- **可移植**:组件 class 使用 `tui-*` 前缀,样式集中在 `frontend/src/components/tactile-ui/styles.css`。
|
||||
- **低依赖**:组件只假设 React/React DOM;图标预设当前使用 `lucide-react`,调用方也可以传自定义 React 节点。
|
||||
- **主题友好**:默认样式通过 CSS variables 暴露,Planet 可以在 Admin 或其它页面按主题覆盖 token。
|
||||
@@ -122,7 +122,7 @@ body[data-admin-theme='dark'] .tui-button {
|
||||
|
||||
## `TactileSwitch`
|
||||
|
||||
开关组件用于二元设置。它不是 iOS 风格大开关,而是与 Docs 主题滑块一致的小型轻触感控件。
|
||||
开关组件用于二元设置。它不是 iOS 风格大开关,而是与文档主题滑块一致的小型轻触感控件。
|
||||
|
||||
```tsx
|
||||
<TactileSwitch
|
||||
|
||||
@@ -16,12 +16,15 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.66.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.67.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.67.0` | feature | `dev` | `pending` | 新增控制台日志实时跟随和运行时错误上报,重构智能星球 Interactable 聚合、wheel 缩放输入、国界壳半径和开发脚本锁文件保护 |
|
||||
| `0.66.3` | bugfix | `dev` | `pending` | 补上 Admin utility module 并放开前端源码 lib 例外,修复控制台动态导入 500 与 Mermaid 包解析失败 |
|
||||
| `0.66.2` | bugfix | `dev` | `pending` | 统一智能星球、控制台和文档的中文显示命名,清理 Docs catalog、使用手册、控制台入口和智能星球设置中的中英混排文案 |
|
||||
| `0.66.1` | bugfix | `dev` | `pending` | CelesTrak active 限频时复用持久下载缓存并完整 fallback group,destroy 保留原始下载缓存,同时修复采集失败 toast 重复弹出 |
|
||||
| `0.66.0` | feature | `dev` | `pending` | Admin 正式化为唯一控制台,新增数据作业/outbox 与 Earth interactables 管线,补齐 AI/采集日志,修复 CelesTrak 完整 active 目录采集和内置源启停判断 |
|
||||
| `0.65.2` | bugfix | `dev` | `pending` | AI Provider 镜像重建判定改为内容 fingerprint 与镜像 label,启动链路改用 frozen uv,避免用户级镜像源污染 `uv.lock`,并加入 Windows 一键启动脚本 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.66.1",
|
||||
"version": "0.67.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -2822,6 +2822,7 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.earth-settings-segmented {
|
||||
@@ -2908,10 +2909,17 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 calc(30px * var(--hud-scale));
|
||||
width: calc(30px * var(--hud-scale));
|
||||
height: calc(30px * var(--hud-scale));
|
||||
min-width: calc(30px * var(--hud-scale));
|
||||
min-height: calc(30px * var(--hud-scale));
|
||||
max-width: calc(30px * var(--hud-scale));
|
||||
max-height: calc(30px * var(--hud-scale));
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(122, 180, 255, 0.24);
|
||||
border-radius: 999px;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.2), transparent 58%),
|
||||
linear-gradient(180deg, rgba(121, 159, 207, 0.18), rgba(72, 101, 139, 0.24));
|
||||
@@ -2926,12 +2934,16 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
}
|
||||
|
||||
.earth-settings-reload-action--text {
|
||||
flex-basis: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
min-width: calc(46px * var(--hud-scale));
|
||||
padding: 0 calc(12px * var(--hud-scale));
|
||||
aspect-ratio: auto;
|
||||
font: inherit;
|
||||
font-size: calc(0.7rem * var(--hud-scale));
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.earth-settings-reload-action[hidden] {
|
||||
@@ -3076,6 +3088,7 @@ label.is-disabled.earth-mobile-settings-card {
|
||||
|
||||
.earth-settings-slider {
|
||||
flex: 1 1 auto;
|
||||
min-width: calc(120px * var(--hud-scale));
|
||||
width: 100%;
|
||||
height: calc(4px * var(--hud-scale));
|
||||
appearance: none;
|
||||
|
||||
@@ -640,7 +640,7 @@
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-nav">
|
||||
<div class="earth-mobile-drawer-nav-copy">
|
||||
<span class="earth-mobile-drawer-nav-kicker">Earth Menu</span>
|
||||
<span class="earth-mobile-drawer-nav-kicker">智能星球菜单</span>
|
||||
<span class="earth-mobile-drawer-nav-title">模块切换</span>
|
||||
</div>
|
||||
<div class="earth-mobile-drawer-tabs-shell">
|
||||
@@ -682,7 +682,7 @@
|
||||
<section class="earth-mobile-drawer-slot is-active" data-drawer-slot="layers">
|
||||
<div class="earth-mobile-page earth-mobile-page--layers">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Layer Control</span>
|
||||
<span class="earth-mobile-page-kicker">图层控制</span>
|
||||
<span id="mobile-layer-summary" class="earth-mobile-page-summary">已启用 0 个图层</span>
|
||||
</div>
|
||||
<div id="mobile-layer-list" class="earth-mobile-layer-list"></div>
|
||||
@@ -691,7 +691,7 @@
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="search">
|
||||
<div class="earth-mobile-page earth-mobile-page--search">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Object Search</span>
|
||||
<span class="earth-mobile-page-kicker">对象搜索</span>
|
||||
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星、算力中心和 BGP 事件</span>
|
||||
</div>
|
||||
<div class="earth-mobile-search-shell">
|
||||
@@ -717,7 +717,7 @@
|
||||
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
|
||||
<div class="earth-mobile-page earth-mobile-page--situation">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Situation</span>
|
||||
<span class="earth-mobile-page-kicker">态势总览</span>
|
||||
<span class="earth-mobile-page-summary">面向移动端整合的全球态势概览</span>
|
||||
</div>
|
||||
<div class="earth-mobile-stats-grid">
|
||||
@@ -757,7 +757,7 @@
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="news">
|
||||
<div class="earth-mobile-page earth-mobile-page--news">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">News</span>
|
||||
<span class="earth-mobile-page-kicker">新闻</span>
|
||||
<span class="earth-mobile-page-summary">跟随当前视角聚焦全球区域新闻</span>
|
||||
</div>
|
||||
<div class="earth-mobile-news-focus">
|
||||
@@ -829,7 +829,7 @@
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="motion">
|
||||
<div class="earth-mobile-page earth-mobile-page--motion">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Motion Capture</span>
|
||||
<span class="earth-mobile-page-kicker">动作捕捉</span>
|
||||
<span class="earth-mobile-page-summary">查看浏览器摄像头画面、骨架连线和当前匹配动作</span>
|
||||
</div>
|
||||
<div class="earth-mobile-motion-empty">动捕调试画面已并入设置里的动捕模式。</div>
|
||||
@@ -838,8 +838,8 @@
|
||||
<section class="earth-mobile-drawer-slot" data-drawer-slot="settings">
|
||||
<div class="earth-mobile-page earth-mobile-page--settings">
|
||||
<div class="earth-mobile-page-intro">
|
||||
<span class="earth-mobile-page-kicker">Settings</span>
|
||||
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的 Earth 配置</span>
|
||||
<span class="earth-mobile-page-kicker">设置</span>
|
||||
<span class="earth-mobile-page-summary">仅保留移动端仍有意义的智能星球配置</span>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-tabs" role="tablist" aria-label="移动端设置分类">
|
||||
<button type="button" class="earth-mobile-settings-tab is-active" data-settings-tab="runtime" aria-selected="true">运行</button>
|
||||
@@ -1139,7 +1139,7 @@
|
||||
<div class="earth-mobile-settings-title">系统</div>
|
||||
<div class="earth-mobile-settings-actions">
|
||||
<button id="mobile-settings-reset" class="earth-mobile-action-btn earth-mobile-action-btn--ghost" type="button">重置设置</button>
|
||||
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开 Admin</a>
|
||||
<a class="earth-mobile-action-btn" href="/admin" target="_blank" rel="noreferrer noopener">打开控制台</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="earth-mobile-settings-group" data-settings-tab-panel="about" hidden>
|
||||
@@ -1659,8 +1659,8 @@
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">Admin</span>
|
||||
<span class="earth-settings-item-subtitle">打开管理后台仪表盘</span>
|
||||
<span class="earth-settings-item-title">控制台</span>
|
||||
<span class="earth-settings-item-subtitle">打开数据源、任务和系统运维工作台</span>
|
||||
</div>
|
||||
<span class="earth-settings-link-meta">
|
||||
<span class="material-symbols-rounded">admin_panel_settings</span>
|
||||
@@ -1674,7 +1674,7 @@
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div class="earth-settings-copy">
|
||||
<span class="earth-settings-item-title">Docs</span>
|
||||
<span class="earth-settings-item-title">文档</span>
|
||||
<span class="earth-settings-item-subtitle">查看使用手册与开发文档</span>
|
||||
</div>
|
||||
<span class="earth-settings-link-meta">
|
||||
|
||||
@@ -214,7 +214,6 @@ const computeCenterIconLayer = createInteractableLayer({
|
||||
},
|
||||
icon: {
|
||||
coordinates: "canvas",
|
||||
colorable: false,
|
||||
fitSize: COMPUTE_CENTER_ICON_FIT_SIZE,
|
||||
glowBlur: 16,
|
||||
getSource({ marker, item }) {
|
||||
|
||||
@@ -64,6 +64,8 @@ export const SURFACE_HOVER_INFO_MODES = {
|
||||
export const DEFAULT_SURFACE_HOVER_INFO_MODE =
|
||||
SURFACE_HOVER_INFO_MODES.FULL;
|
||||
|
||||
export const EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET = 0.48;
|
||||
|
||||
export const CRUISE_CONFIG = {
|
||||
dwellMs: 7_000,
|
||||
focusDurationMs: 1_400,
|
||||
@@ -233,8 +235,12 @@ export const COUNTRY_BOUNDARY_CONFIG = {
|
||||
tilePrefetchRing: 1,
|
||||
tileDebounceMs: 180,
|
||||
tileCacheLimit: 150,
|
||||
lineAltitudeOffset: 0.115,
|
||||
hoverAltitudeOffset: 0.115,
|
||||
// Keep boundary/coastline strokes on the same shell as the high-res earth
|
||||
// texture overlay. A lower or higher radius creates visible parallax while
|
||||
// the globe rotates.
|
||||
lineAltitudeOffset: EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET,
|
||||
hoverAltitudeOffset: EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET,
|
||||
claimLineAltitudeOffset: 0,
|
||||
hoverMissStickyMs: 160,
|
||||
lineColor: 0x7fc7ff,
|
||||
lineOpacity: 0.58,
|
||||
@@ -540,7 +546,7 @@ export const EARTH_MATERIAL_CONFIG = {
|
||||
shininess: 12,
|
||||
emissive: 0x010609,
|
||||
opacity: 1,
|
||||
textureOverlayAltitudeOffset: 0.48,
|
||||
textureOverlayAltitudeOffset: EARTH_SURFACE_TEXTURE_ALTITUDE_OFFSET,
|
||||
textureOverlayOpacity: 0.88,
|
||||
textureOverlayRenderOrder: 0.96,
|
||||
textureOverlaySpecular: 0x05080d,
|
||||
|
||||
181
frontend/public/earth/js/controls.js
vendored
181
frontend/public/earth/js/controls.js
vendored
@@ -185,6 +185,11 @@ const KEYBOARD_ROTATION_STOP_SPEED = 0.012;
|
||||
const KEYBOARD_ZOOM_STEP = 0.1;
|
||||
const WHEEL_ZOOM_STEP = 0.1;
|
||||
const WHEEL_ZOOM_DURATION_MS = 180;
|
||||
const WHEEL_TRACKPAD_PIXEL_THRESHOLD = 48;
|
||||
const WHEEL_TRACKPAD_DEADZONE = 0.35;
|
||||
const WHEEL_TRACKPAD_RESIDUAL_WINDOW_MS = 140;
|
||||
const WHEEL_TRACKPAD_RESIDUAL_RATIO = 0.65;
|
||||
const WHEEL_TRACKPAD_SENSITIVITY = 0.0024;
|
||||
const TARGET_SWITCH_ZOOM_IN_PHASE = 0.28;
|
||||
const TARGET_SWITCH_ROTATE_PHASE = 0.5;
|
||||
let settingsModalTimer = null;
|
||||
@@ -655,7 +660,7 @@ function stopKeyboardRotationControl({ actionId = null, restoreAutoRotate = fals
|
||||
}
|
||||
|
||||
function applyKeyboardZoom(direction) {
|
||||
setZoomLevel(zoomLevel + direction * KEYBOARD_ZOOM_STEP, activeCamera);
|
||||
setZoomLevel(getZoomLevelFromCamera(activeCamera) + direction * KEYBOARD_ZOOM_STEP, activeCamera);
|
||||
showZoomStatusCapsule({ force: true });
|
||||
}
|
||||
|
||||
@@ -1318,6 +1323,19 @@ function clampEarthZoomLevel(nextZoom) {
|
||||
return Math.min(CONFIG.maxZoom, Math.max(CONFIG.minZoom, parsedZoom));
|
||||
}
|
||||
|
||||
function getZoomLevelFromCamera(camera = activeCamera) {
|
||||
const cameraZ = Number(camera?.position?.z);
|
||||
if (!Number.isFinite(cameraZ) || cameraZ <= 0) {
|
||||
return clampEarthZoomLevel(zoomLevel);
|
||||
}
|
||||
return clampEarthZoomLevel(CONFIG.defaultCameraZ / cameraZ);
|
||||
}
|
||||
|
||||
function syncZoomLevelFromCamera(camera = activeCamera) {
|
||||
zoomLevel = getZoomLevelFromCamera(camera);
|
||||
return zoomLevel;
|
||||
}
|
||||
|
||||
function formatZoomPercent(zoom) {
|
||||
return `${Math.round(zoom * 100)}%`;
|
||||
}
|
||||
@@ -2188,8 +2206,7 @@ function setDefaultEarthZoom(nextZoom, { persist = true, applyToCurrentView = tr
|
||||
syncDefaultEarthZoomUi(defaultEarthZoom);
|
||||
|
||||
if (applyToCurrentView && activeCamera) {
|
||||
zoomLevel = defaultEarthZoom;
|
||||
applyZoom(activeCamera);
|
||||
setZoomLevel(defaultEarthZoom, activeCamera);
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
@@ -3110,12 +3127,7 @@ export function applyImmediateView(targetEarthObj, camera, options = {}) {
|
||||
targetEarthObj.rotation.x = nextRotation.x;
|
||||
targetEarthObj.rotation.y = nextRotation.y;
|
||||
targetEarthObj.rotation.z = nextRotation.z;
|
||||
zoomLevel = zoom;
|
||||
|
||||
if (camera) {
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
}
|
||||
setZoomLevel(zoom, camera);
|
||||
}
|
||||
|
||||
export function setZoomLevel(nextZoom, camera = activeCamera) {
|
||||
@@ -3127,13 +3139,16 @@ export function setZoomLevel(nextZoom, camera = activeCamera) {
|
||||
return zoomLevel;
|
||||
}
|
||||
|
||||
export function showZoomStatusCapsule({ force = false } = {}) {
|
||||
export function showZoomStatusCapsule({ force = false, zoom = null } = {}) {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastZoomStatusUpdateTime < ZOOM_STATUS_UPDATE_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
lastZoomStatusUpdateTime = now;
|
||||
showGestureStatusMessage(`缩放 ${Math.round(zoomLevel * 100)}%`, "info");
|
||||
const currentZoom = Number.isFinite(Number(zoom))
|
||||
? clampEarthZoomLevel(zoom)
|
||||
: syncZoomLevelFromCamera(activeCamera);
|
||||
showGestureStatusMessage(`缩放 ${Math.round(currentZoom * 100)}%`, "info");
|
||||
}
|
||||
|
||||
function cancelSettingsSheetAnimation() {
|
||||
@@ -4692,27 +4707,25 @@ function setupZoomControls(camera) {
|
||||
const MAX_PERCENT = CONFIG.maxZoom * 100;
|
||||
|
||||
function doZoomStep(direction) {
|
||||
let currentPercent = Math.round(zoomLevel * 100);
|
||||
let currentPercent = Math.round(getZoomLevelFromCamera(camera) * 100);
|
||||
let newPercent =
|
||||
direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
|
||||
|
||||
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
|
||||
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
|
||||
|
||||
zoomLevel = newPercent / 100;
|
||||
applyZoom(camera);
|
||||
setZoomLevel(newPercent / 100, camera);
|
||||
showZoomStatusCapsule({ force: true });
|
||||
}
|
||||
|
||||
function doContinuousZoom(direction) {
|
||||
let currentPercent = Math.round(zoomLevel * 100);
|
||||
let currentPercent = Math.round(getZoomLevelFromCamera(camera) * 100);
|
||||
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
|
||||
|
||||
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
|
||||
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
|
||||
|
||||
zoomLevel = newPercent / 100;
|
||||
applyZoom(camera);
|
||||
setZoomLevel(newPercent / 100, camera);
|
||||
showZoomStatusCapsule();
|
||||
}
|
||||
|
||||
@@ -4775,10 +4788,8 @@ function setupZoomControls(camera) {
|
||||
bindListener(zoomOut, "touchend", () => handleMouseUp(-1));
|
||||
|
||||
bindListener(zoomValue, "click", () => {
|
||||
const startZoomVal = zoomLevel;
|
||||
const startZoomVal = getZoomLevelFromCamera(camera);
|
||||
const targetZoom = getDefaultEarthZoomLevel();
|
||||
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
|
||||
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
|
||||
|
||||
animateValue(
|
||||
0,
|
||||
@@ -4786,14 +4797,11 @@ function setupZoomControls(camera) {
|
||||
600,
|
||||
(progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
const distance =
|
||||
startDistance + (targetDistance - startDistance) * ease;
|
||||
updateZoomDisplay(zoomLevel, distance.toFixed(0));
|
||||
const nextZoom = startZoomVal + (targetZoom - startZoomVal) * ease;
|
||||
setZoomLevel(nextZoom, camera);
|
||||
},
|
||||
() => {
|
||||
zoomLevel = targetZoom;
|
||||
setZoomLevel(targetZoom, camera);
|
||||
showStatusMessage(getZoomResetStatusMessage(targetZoom), "info");
|
||||
},
|
||||
);
|
||||
@@ -4802,9 +4810,51 @@ function setupZoomControls(camera) {
|
||||
|
||||
function setupWheelZoom(camera, renderer) {
|
||||
let wheelZoomFrameId = null;
|
||||
let wheelZoomTarget = zoomLevel;
|
||||
let wheelZoomStart = zoomLevel;
|
||||
let wheelZoomTarget = getZoomLevelFromCamera(camera);
|
||||
let wheelZoomStart = wheelZoomTarget;
|
||||
let wheelZoomStartAt = 0;
|
||||
let lastTrackpadDirection = 0;
|
||||
let suppressedTrackpadDirection = 0;
|
||||
let suppressedTrackpadUntil = 0;
|
||||
let suppressedTrackpadMagnitude = 0;
|
||||
|
||||
function getWheelPixelDelta(event) {
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) {
|
||||
return event.deltaY * 16;
|
||||
}
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
|
||||
return event.deltaY * window.innerHeight;
|
||||
}
|
||||
return event.deltaY;
|
||||
}
|
||||
|
||||
function isTrackpadWheel(event, pixelDelta) {
|
||||
return event.deltaMode === WheelEvent.DOM_DELTA_PIXEL &&
|
||||
Math.abs(pixelDelta) < WHEEL_TRACKPAD_PIXEL_THRESHOLD;
|
||||
}
|
||||
|
||||
function shouldSuppressTrackpadResidual(pixelDelta) {
|
||||
const direction = Math.sign(pixelDelta);
|
||||
const magnitude = Math.abs(pixelDelta);
|
||||
const now = performance.now();
|
||||
return direction !== 0 &&
|
||||
direction === suppressedTrackpadDirection &&
|
||||
now < suppressedTrackpadUntil &&
|
||||
magnitude < suppressedTrackpadMagnitude * WHEEL_TRACKPAD_RESIDUAL_RATIO;
|
||||
}
|
||||
|
||||
function recordTrackpadWheel(pixelDelta) {
|
||||
const direction = Math.sign(pixelDelta);
|
||||
const magnitude = Math.abs(pixelDelta);
|
||||
if (direction === 0) return;
|
||||
const now = performance.now();
|
||||
if (lastTrackpadDirection !== 0 && direction !== lastTrackpadDirection) {
|
||||
suppressedTrackpadDirection = lastTrackpadDirection;
|
||||
suppressedTrackpadUntil = now + WHEEL_TRACKPAD_RESIDUAL_WINDOW_MS;
|
||||
suppressedTrackpadMagnitude = magnitude;
|
||||
}
|
||||
lastTrackpadDirection = direction;
|
||||
}
|
||||
|
||||
function stopWheelZoomAnimation() {
|
||||
if (wheelZoomFrameId !== null) {
|
||||
@@ -4824,28 +4874,55 @@ function setupWheelZoom(camera, renderer) {
|
||||
1,
|
||||
);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
zoomLevel = wheelZoomStart + (wheelZoomTarget - wheelZoomStart) * ease;
|
||||
applyZoom(camera);
|
||||
setZoomLevel(
|
||||
wheelZoomStart + (wheelZoomTarget - wheelZoomStart) * ease,
|
||||
camera,
|
||||
);
|
||||
|
||||
if (progress < 1) {
|
||||
wheelZoomFrameId = window.requestAnimationFrame(animateWheelZoom);
|
||||
return;
|
||||
}
|
||||
|
||||
zoomLevel = wheelZoomTarget;
|
||||
applyZoom(camera);
|
||||
setZoomLevel(wheelZoomTarget, camera);
|
||||
wheelZoomFrameId = null;
|
||||
wheelZoomStartAt = 0;
|
||||
}
|
||||
|
||||
function startWheelZoomAnimation() {
|
||||
wheelZoomStart = zoomLevel;
|
||||
wheelZoomStart = getZoomLevelFromCamera(camera);
|
||||
wheelZoomStartAt = 0;
|
||||
if (wheelZoomFrameId === null) {
|
||||
wheelZoomFrameId = window.requestAnimationFrame(animateWheelZoom);
|
||||
}
|
||||
}
|
||||
|
||||
function applyMouseWheelZoom(direction) {
|
||||
suppressedTrackpadDirection = 0;
|
||||
suppressedTrackpadUntil = 0;
|
||||
const baseZoom = wheelZoomFrameId === null
|
||||
? getZoomLevelFromCamera(camera)
|
||||
: wheelZoomTarget;
|
||||
wheelZoomTarget = clampEarthZoomLevel(
|
||||
baseZoom + direction * WHEEL_ZOOM_STEP,
|
||||
);
|
||||
stopWheelZoomAnimation();
|
||||
startWheelZoomAnimation();
|
||||
showZoomStatusCapsule({ force: true, zoom: wheelZoomTarget });
|
||||
}
|
||||
|
||||
function applyTrackpadWheelZoom(pixelDelta) {
|
||||
if (Math.abs(pixelDelta) < WHEEL_TRACKPAD_DEADZONE) return;
|
||||
if (shouldSuppressTrackpadResidual(pixelDelta)) return;
|
||||
stopWheelZoomAnimation();
|
||||
const currentZoom = getZoomLevelFromCamera(camera);
|
||||
const nextZoom = currentZoom * Math.exp(-pixelDelta * WHEEL_TRACKPAD_SENSITIVITY);
|
||||
wheelZoomTarget = setZoomLevel(nextZoom, camera);
|
||||
wheelZoomStart = wheelZoomTarget;
|
||||
recordTrackpadWheel(pixelDelta);
|
||||
showZoomStatusCapsule({ force: true, zoom: wheelZoomTarget });
|
||||
}
|
||||
|
||||
cleanupFns.push(stopWheelZoomAnimation);
|
||||
|
||||
bindListener(
|
||||
@@ -4853,25 +4930,17 @@ function setupWheelZoom(camera, renderer) {
|
||||
"wheel",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
const baseZoom =
|
||||
wheelZoomFrameId === null ? zoomLevel : wheelZoomTarget;
|
||||
wheelZoomTarget = clampEarthZoomLevel(
|
||||
baseZoom + direction * WHEEL_ZOOM_STEP,
|
||||
);
|
||||
startWheelZoomAnimation();
|
||||
showZoomStatusCapsule({ force: true });
|
||||
const pixelDelta = getWheelPixelDelta(e);
|
||||
if (isTrackpadWheel(e, pixelDelta)) {
|
||||
applyTrackpadWheelZoom(pixelDelta);
|
||||
return;
|
||||
}
|
||||
applyMouseWheelZoom(pixelDelta < 0 ? 1 : -1);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
|
||||
function applyZoom(camera) {
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
const distance = camera.position.z.toFixed(0);
|
||||
updateZoomDisplay(zoomLevel, distance);
|
||||
}
|
||||
|
||||
function animateValue(start, end, duration, onUpdate, onComplete) {
|
||||
const animationToken = ++focusViewAnimationToken;
|
||||
const startTime = performance.now();
|
||||
@@ -5800,7 +5869,7 @@ export function focusEarthView(camera, options = {}) {
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
const startRotZ = earthObj.rotation.z;
|
||||
const startZoom = zoomLevel;
|
||||
const startZoom = getZoomLevelFromCamera(camera);
|
||||
const defaultZoom = getDefaultEarthZoomLevel();
|
||||
const shouldRestoreZoomViaDefault =
|
||||
zoomTransitionMode === "restore-current-via-default" &&
|
||||
@@ -5829,30 +5898,26 @@ export function focusEarthView(camera, options = {}) {
|
||||
if (progress < rotateStartProgress) {
|
||||
const zoomProgress = progress / rotateStartProgress;
|
||||
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
|
||||
zoomLevel = startZoom + (defaultZoom - startZoom) * zoomEase;
|
||||
setZoomLevel(startZoom + (defaultZoom - startZoom) * zoomEase, camera);
|
||||
} else if (progress <= rotateEndProgress) {
|
||||
zoomLevel = defaultZoom;
|
||||
setZoomLevel(defaultZoom, camera);
|
||||
} else {
|
||||
const zoomProgress = (progress - rotateEndProgress) / (1 - rotateEndProgress);
|
||||
const zoomEase = 1 - Math.pow(1 - zoomProgress, 3);
|
||||
zoomLevel = defaultZoom + (startZoom - defaultZoom) * zoomEase;
|
||||
setZoomLevel(defaultZoom + (startZoom - defaultZoom) * zoomEase, camera);
|
||||
}
|
||||
} else {
|
||||
earthObj.rotation.x = startRotX + (nextRotation.x - startRotX) * ease;
|
||||
earthObj.rotation.y = startRotY + (nextRotation.y - startRotY) * ease;
|
||||
earthObj.rotation.z = startRotZ + (nextRotation.z - startRotZ) * ease;
|
||||
zoomLevel = startZoom + (zoom - startZoom) * ease;
|
||||
setZoomLevel(startZoom + (zoom - startZoom) * ease, camera);
|
||||
}
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
},
|
||||
() => {
|
||||
zoomLevel = shouldRestoreZoomViaDefault ? startZoom : zoom;
|
||||
setZoomLevel(shouldRestoreZoomViaDefault ? startZoom : zoom, camera);
|
||||
earthObj.rotation.x = nextRotation.x;
|
||||
earthObj.rotation.y = nextRotation.y;
|
||||
earthObj.rotation.z = nextRotation.z;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
if (!suppressStatus) {
|
||||
showStatusMessage("视角已重置", "info");
|
||||
}
|
||||
@@ -5863,7 +5928,7 @@ export function focusEarthView(camera, options = {}) {
|
||||
}
|
||||
|
||||
export function getZoomLevel() {
|
||||
return zoomLevel;
|
||||
return syncZoomLevelFromCamera(activeCamera);
|
||||
}
|
||||
|
||||
export function getDefaultEarthZoomLevel() {
|
||||
|
||||
@@ -210,7 +210,7 @@ function boundaryLineRadius({ claim = false } = {}) {
|
||||
return (
|
||||
CONFIG.earthRadius +
|
||||
COUNTRY_BOUNDARY_CONFIG.lineAltitudeOffset +
|
||||
(claim ? 0.018 : 0)
|
||||
(claim ? COUNTRY_BOUNDARY_CONFIG.claimLineAltitudeOffset : 0)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ import {
|
||||
showStatusMessage,
|
||||
queueStatusMessage,
|
||||
updateCoordinatesDisplay,
|
||||
updateZoomDisplay,
|
||||
updateEarthStats,
|
||||
setEarthStatValue,
|
||||
setLoading,
|
||||
@@ -301,6 +300,8 @@ export let scene;
|
||||
export let camera;
|
||||
export let renderer;
|
||||
|
||||
const WEBGL_GPU_DIAGNOSTICS_EVENT = "earth:gpu-diagnostics";
|
||||
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let targetRotation = { x: 0, y: 0 };
|
||||
@@ -451,6 +452,29 @@ function getViewportAspect() {
|
||||
return window.innerWidth / window.innerHeight;
|
||||
}
|
||||
|
||||
function getWebGLGpuDiagnostics(activeRenderer) {
|
||||
const gl = activeRenderer?.getContext?.();
|
||||
if (!gl) return null;
|
||||
const debugInfo = gl.getExtension?.("WEBGL_debug_renderer_info");
|
||||
const attrs = gl.getContextAttributes?.() || {};
|
||||
return {
|
||||
requestedPowerPreference: "high-performance",
|
||||
contextPowerPreference: attrs.powerPreference || null,
|
||||
vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR),
|
||||
renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
|
||||
antialias: Boolean(attrs.antialias),
|
||||
alpha: Boolean(attrs.alpha),
|
||||
};
|
||||
}
|
||||
|
||||
function publishWebGLGpuDiagnostics(activeRenderer) {
|
||||
const diagnostics = getWebGLGpuDiagnostics(activeRenderer);
|
||||
if (!diagnostics) return;
|
||||
window.__planetEarthGpu = diagnostics;
|
||||
console.info("[智能星球] WebGL GPU diagnostics", diagnostics);
|
||||
window.dispatchEvent(new CustomEvent(WEBGL_GPU_DIAGNOSTICS_EVENT, { detail: diagnostics }));
|
||||
}
|
||||
|
||||
function syncRendererViewport() {
|
||||
if (!camera || !renderer) return;
|
||||
camera.aspect = getViewportAspect();
|
||||
@@ -793,12 +817,30 @@ function isSameVessel(marker1, marker2) {
|
||||
return Boolean(marker1 && marker2 && marker1.userData?.mmsi === marker2.userData?.mmsi);
|
||||
}
|
||||
|
||||
function getFirstObjectIntersection(intersections) {
|
||||
return intersections.find((hit) => !hit?.cluster && hit?.object)?.object || null;
|
||||
}
|
||||
|
||||
function getPrimaryClusterHit(...intersectionGroups) {
|
||||
return intersectionGroups
|
||||
.flat()
|
||||
.filter((hit) => hit?.cluster && Number(hit.clusterCount) > 1)
|
||||
.sort((a, b) => a.distancePxSq - b.distancePxSq)[0] || null;
|
||||
}
|
||||
|
||||
function getClusterBriefHtml(hit) {
|
||||
const count = Number(hit?.clusterCount || hit?.clusterMarkers?.length || 0);
|
||||
return `<strong>共 ${count} 个对象</strong><br><span>放大后可查看单个图标</span>`;
|
||||
}
|
||||
|
||||
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
|
||||
if (bgpAnomalyIntersects.length > 0) {
|
||||
return bgpAnomalyIntersects[0].object;
|
||||
const anomalyMarker = getFirstObjectIntersection(bgpAnomalyIntersects);
|
||||
if (anomalyMarker) {
|
||||
return anomalyMarker;
|
||||
}
|
||||
if (bgpCollectorIntersects.length > 0) {
|
||||
return bgpCollectorIntersects[0].object;
|
||||
const collectorMarker = getFirstObjectIntersection(bgpCollectorIntersects);
|
||||
if (collectorMarker) {
|
||||
return collectorMarker;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -808,8 +850,8 @@ function getPrimaryBGPClickTarget(
|
||||
bgpAnomalyIntersects,
|
||||
bgpCollectorIntersects,
|
||||
) {
|
||||
const anomalyMarker = bgpAnomalyIntersects[0]?.object || null;
|
||||
const collectorMarker = bgpCollectorIntersects[0]?.object || null;
|
||||
const anomalyMarker = getFirstObjectIntersection(bgpAnomalyIntersects);
|
||||
const collectorMarker = getFirstObjectIntersection(bgpCollectorIntersects);
|
||||
if (!anomalyMarker && !collectorMarker) return null;
|
||||
if (!anomalyMarker) return collectorMarker;
|
||||
if (!collectorMarker) return anomalyMarker;
|
||||
@@ -952,19 +994,23 @@ function getMotionIconCandidates() {
|
||||
const candidates = [];
|
||||
if (getShowBGP()) {
|
||||
getBGPEventIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||||
if (hit.cluster || !hit.object) return;
|
||||
candidates.push({ type: "bgp", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||||
});
|
||||
getBGPCollectorIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||||
if (hit.cluster || !hit.object) return;
|
||||
candidates.push({ type: "bgp_collector", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||||
});
|
||||
}
|
||||
if (getShowComputeCenters()) {
|
||||
getComputeCenterIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||||
if (hit.cluster || !hit.object) return;
|
||||
candidates.push({ type: "compute_center", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||||
});
|
||||
}
|
||||
if (getShowVessels()) {
|
||||
getVesselIconPointerIntersections(sharedOptions).forEach((hit) => {
|
||||
if (hit.cluster || !hit.object) return;
|
||||
candidates.push({ type: "vessel", object: hit.object, screen: getMotionScreenPointFromWorld(hit.point), distancePxSq: hit.distancePxSq });
|
||||
});
|
||||
}
|
||||
@@ -3961,7 +4007,7 @@ export function init() {
|
||||
0.1,
|
||||
5000,
|
||||
);
|
||||
camera.position.z = CONFIG.defaultCameraZ;
|
||||
setZoomLevel(getDefaultEarthZoomLevel(), camera);
|
||||
setSatelliteCamera(camera);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
@@ -3972,6 +4018,7 @@ export function init() {
|
||||
syncRendererViewport();
|
||||
renderer.setClearColor(0x02040a, 1);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
publishWebGLGpuDiagnostics(renderer);
|
||||
|
||||
const container = document.getElementById("container");
|
||||
if (container) {
|
||||
@@ -5287,13 +5334,17 @@ function onMouseMove(event) {
|
||||
bgpAnomalyIntersects,
|
||||
bgpCollectorIntersects,
|
||||
);
|
||||
const hoveredClusterHit = getPrimaryClusterHit(
|
||||
bgpAnomalyIntersects,
|
||||
bgpCollectorIntersects,
|
||||
computeCenterIntersects,
|
||||
);
|
||||
|
||||
if (hoveredBGP && !isSameBGPMarker(hoveredBGP, hoveredBGPMarker)) {
|
||||
clearTransientHoverState();
|
||||
}
|
||||
|
||||
const hoveredComputeCenterMarker =
|
||||
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
|
||||
const hoveredComputeCenterMarker = getFirstObjectIntersection(computeCenterIntersects);
|
||||
const hoveredVesselMarker =
|
||||
vesselPick.checked && vesselIntersects.length > 0 ? vesselIntersects[0].object : null;
|
||||
const earthPoint = screenToEarthCoords(
|
||||
@@ -5357,6 +5408,20 @@ function onMouseMove(event) {
|
||||
let objectTooltipShown = false;
|
||||
|
||||
if (
|
||||
hoveredClusterHit &&
|
||||
!hoveredBGPMarker &&
|
||||
!hoveredComputeCenterMarker &&
|
||||
lockedObjectType !== "bgp" &&
|
||||
lockedObjectType !== "bgp_collector" &&
|
||||
lockedObjectType !== "compute_center"
|
||||
) {
|
||||
showTooltip(
|
||||
event.clientX + TOOLTIP_CURSOR_OFFSET,
|
||||
event.clientY + TOOLTIP_CURSOR_OFFSET,
|
||||
getClusterBriefHtml(hoveredClusterHit),
|
||||
);
|
||||
objectTooltipShown = true;
|
||||
} else if (
|
||||
hoveredBGPMarker &&
|
||||
getShowBGP() &&
|
||||
lockedObjectType !== "bgp" &&
|
||||
@@ -5663,9 +5728,7 @@ function onClick(event) {
|
||||
const clickedBGPMarker = getShowBGP()
|
||||
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
|
||||
: null;
|
||||
const clickedComputeCenterMarker = computeCenterIntersects.length > 0
|
||||
? computeCenterIntersects[0].object
|
||||
: null;
|
||||
const clickedComputeCenterMarker = getFirstObjectIntersection(computeCenterIntersects);
|
||||
const clickedVesselMarker = vesselIntersects.length > 0
|
||||
? vesselIntersects[0].object
|
||||
: null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import Login from './pages/Login/Login'
|
||||
import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary'
|
||||
|
||||
const Register = lazy(() => import('./pages/Register/Register'))
|
||||
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
|
||||
@@ -51,7 +52,7 @@ function App() {
|
||||
<Route path={DOCS_ROUTE} element={<Docs />} />
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
|
||||
<Route path="/*" element={<AdminRoutes />} />
|
||||
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
39
frontend/src/admin/components/AdminErrorBoundary.tsx
Normal file
39
frontend/src/admin/components/AdminErrorBoundary.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { reportAdminRuntimeLog } from '../runtimeLogs'
|
||||
|
||||
type AdminErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type AdminErrorBoundaryState = {
|
||||
hasError: boolean
|
||||
}
|
||||
|
||||
export class AdminErrorBoundary extends Component<AdminErrorBoundaryProps, AdminErrorBoundaryState> {
|
||||
state: AdminErrorBoundaryState = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
void reportAdminRuntimeLog({
|
||||
level: 'error',
|
||||
category: 'react-error-boundary',
|
||||
module: 'admin',
|
||||
message: error.message || '控制台渲染错误',
|
||||
detail: `${error.stack || error.message}\n${errorInfo.componentStack || ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="app-route-loading">
|
||||
<div className="app-route-loading__message">控制台发生错误,请刷新页面重试。</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import Scrollbar from '../../../components/Scrollbar/Scrollbar'
|
||||
import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import { useAdminTheme, type AdminThemeMode } from '../../design/theme'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
import { adminRouteGroups, getVisibleAdminRoutes } from '../../routes/manifest'
|
||||
import { useAdminSearch } from '../../search/AdminSearchContext'
|
||||
import { Button } from '../ui/button'
|
||||
@@ -122,8 +122,8 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
</Button>
|
||||
{!collapsed ? (
|
||||
<div className="admin__brand-copy">
|
||||
<span className="admin__brand-text">Planet</span>
|
||||
<span className="admin__brand-subtitle">Admin</span>
|
||||
<span className="admin__brand-text">智能星球</span>
|
||||
<span className="admin__brand-subtitle">控制台</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -208,7 +208,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
<strong>v{packageJson.version}</strong>
|
||||
</div>
|
||||
<SegmentedControl<AdminThemeMode>
|
||||
ariaLabel="Admin 主题"
|
||||
ariaLabel="控制台主题"
|
||||
className="admin__theme-control admin__theme-control--sider"
|
||||
options={themeOptions}
|
||||
scale={0.72}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
type BadgeTone = 'default' | 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'cyan' | 'slate'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('an-card', className)} {...props} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => <input ref={ref} className={cn('an-input', className)} {...props} />,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { cn } from '../../utils'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../co
|
||||
import { Dialog } from '../components/ui/dialog'
|
||||
import { Select } from '../components/ui/select'
|
||||
import { useToast } from '../components/ui/toast'
|
||||
import { formatNumber } from '../lib/utils'
|
||||
import { formatNumber } from '../utils'
|
||||
|
||||
interface Stats {
|
||||
total_datasources: number
|
||||
@@ -334,7 +334,7 @@ function DashboardContent() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="primary">
|
||||
<Link to="/earth"><Globe2 size={16} />访问 Earth</Link>
|
||||
<Link to="/earth"><Globe2 size={16} />访问智能星球</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { Copy, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Copy, Pause, Play, RefreshCw, Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
@@ -20,6 +22,7 @@ const LOG_LEVEL_OPTIONS = [
|
||||
{ value: 'info', label: '信息' },
|
||||
{ value: 'debug', label: '调试' },
|
||||
]
|
||||
const LOG_TAIL_CHANNEL = 'logs_tail'
|
||||
|
||||
interface LogSourceSummary {
|
||||
source_id: string
|
||||
@@ -48,26 +51,44 @@ interface LogSnapshot {
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
type StoredLogFilters = {
|
||||
selectedSource?: string
|
||||
lineLimit?: number
|
||||
level?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
searchQuery?: string
|
||||
follow?: boolean
|
||||
}
|
||||
|
||||
function readStoredFilters() {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(LOG_FILTER_STORAGE_KEY)
|
||||
return rawValue ? JSON.parse(rawValue) as {
|
||||
selectedSource?: string
|
||||
lineLimit?: number
|
||||
level?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
searchQuery?: string
|
||||
} : null
|
||||
return rawValue ? JSON.parse(rawValue) as StoredLogFilters : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function readUrlFilters(search: string): StoredLogFilters {
|
||||
const params = new URLSearchParams(search)
|
||||
const lineLimit = Number(params.get('limit') || '')
|
||||
return {
|
||||
selectedSource: params.get('source') || undefined,
|
||||
lineLimit: Number.isFinite(lineLimit) && lineLimit > 0 ? lineLimit : undefined,
|
||||
level: params.get('level') || undefined,
|
||||
startDate: params.get('start_date') || undefined,
|
||||
endDate: params.get('end_date') || undefined,
|
||||
searchQuery: params.get('search') || undefined,
|
||||
follow: params.get('follow') === '1',
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === 'ok') return 'success'
|
||||
if (status === 'missing' || status === 'empty') return 'warning'
|
||||
if (status === 'missing') return 'warning'
|
||||
if (status === 'empty') return 'neutral'
|
||||
if (status.includes('unavailable')) return 'danger'
|
||||
return 'neutral'
|
||||
}
|
||||
@@ -89,27 +110,75 @@ function getErrorMessage(error: unknown, fallback: string) {
|
||||
|
||||
export default function Logs() {
|
||||
const storedFilters = readStoredFilters()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const urlFilters = readUrlFilters(location.search)
|
||||
const { user } = useAuthStore()
|
||||
const { toast } = useToast()
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const [sources, setSources] = useState<LogSourceSummary[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState(storedFilters?.selectedSource || 'backend')
|
||||
const [lineLimit, setLineLimit] = useState(storedFilters?.lineLimit || 200)
|
||||
const [level, setLevel] = useState(storedFilters?.level || 'all')
|
||||
const [startDate, setStartDate] = useState(storedFilters?.startDate || '')
|
||||
const [endDate, setEndDate] = useState(storedFilters?.endDate || '')
|
||||
const [searchQuery, setSearchQuery] = useState(storedFilters?.searchQuery || '')
|
||||
const [submittedSearch, setSubmittedSearch] = useState(storedFilters?.searchQuery || '')
|
||||
const [selectedSource, setSelectedSource] = useState(urlFilters.selectedSource || storedFilters?.selectedSource || 'backend')
|
||||
const [lineLimit, setLineLimit] = useState(urlFilters.lineLimit || storedFilters?.lineLimit || 200)
|
||||
const [level, setLevel] = useState(urlFilters.level || storedFilters?.level || 'all')
|
||||
const [startDate, setStartDate] = useState(urlFilters.startDate || storedFilters?.startDate || '')
|
||||
const [endDate, setEndDate] = useState(urlFilters.endDate || storedFilters?.endDate || '')
|
||||
const [searchQuery, setSearchQuery] = useState(urlFilters.searchQuery ?? storedFilters?.searchQuery ?? '')
|
||||
const [submittedSearch, setSubmittedSearch] = useState(urlFilters.searchQuery ?? storedFilters?.searchQuery ?? '')
|
||||
const [followEnabled, setFollowEnabled] = useState(Boolean(urlFilters.follow || storedFilters?.follow))
|
||||
const [snapshot, setSnapshot] = useState<LogSnapshot | null>(null)
|
||||
const [sourcesLoading, setSourcesLoading] = useState(false)
|
||||
const [logLoading, setLogLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const shouldStickToBottomRef = useRef(true)
|
||||
|
||||
const selectedSourceInfo = useMemo(
|
||||
() => sources.find((source) => source.source_id === selectedSource) || null,
|
||||
[selectedSource, sources],
|
||||
)
|
||||
const hasActiveFilters = lineLimit !== 200 || level !== 'all' || Boolean(startDate || endDate || submittedSearch.trim() || searchQuery.trim())
|
||||
const hasActiveFilters = lineLimit !== 200 || level !== 'all' || Boolean(startDate || endDate || submittedSearch.trim() || searchQuery.trim() || followEnabled)
|
||||
|
||||
const { connected: followConnected, sendMessage } = useWebSocket({
|
||||
autoConnect: followEnabled && isSuperAdmin,
|
||||
onMessage: (message) => {
|
||||
if (message.channel !== LOG_TAIL_CHANNEL || !message.payload) return
|
||||
const payload = message.payload as { mode?: string; source_id?: string; lines?: unknown; line_count?: number; status?: string }
|
||||
if (payload.source_id !== selectedSource) return
|
||||
const incomingLines = Array.isArray(payload.lines) ? payload.lines.filter((line): line is string => typeof line === 'string') : []
|
||||
setSnapshot((current) => {
|
||||
const base = current || {
|
||||
source_id: selectedSource,
|
||||
name: selectedSourceInfo?.name || selectedSource,
|
||||
kind: selectedSourceInfo?.kind || 'unknown',
|
||||
location: selectedSourceInfo?.location || '',
|
||||
description: selectedSourceInfo?.description || '',
|
||||
category: selectedSourceInfo?.category || '',
|
||||
status: payload.status || 'ok',
|
||||
level,
|
||||
selected_levels: level === 'all' ? [] : [level],
|
||||
search_query: submittedSearch,
|
||||
available_levels: ['all', 'error', 'warning', 'info', 'debug'],
|
||||
line_limit: lineLimit,
|
||||
line_count: 0,
|
||||
lines: [],
|
||||
}
|
||||
const nextLines = payload.mode === 'snapshot'
|
||||
? incomingLines
|
||||
: [...(base.lines || []), ...incomingLines].slice(-lineLimit)
|
||||
return {
|
||||
...base,
|
||||
status: payload.status || base.status,
|
||||
line_limit: lineLimit,
|
||||
line_count: nextLines.length,
|
||||
lines: nextLines,
|
||||
}
|
||||
})
|
||||
setErrorMessage(null)
|
||||
},
|
||||
onError: () => {
|
||||
setErrorMessage('日志跟随连接失败,可暂停后使用手动刷新。')
|
||||
},
|
||||
})
|
||||
|
||||
const fetchSources = async () => {
|
||||
if (!isSuperAdmin) return
|
||||
@@ -159,8 +228,8 @@ export default function Logs() {
|
||||
}, [isSuperAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSnapshot()
|
||||
}, [isSuperAdmin, selectedSource, lineLimit, level, startDate, endDate, submittedSearch])
|
||||
if (!followEnabled) void fetchSnapshot()
|
||||
}, [isSuperAdmin, selectedSource, lineLimit, level, startDate, endDate, submittedSearch, followEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
@@ -178,8 +247,72 @@ export default function Logs() {
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery: submittedSearch,
|
||||
follow: followEnabled,
|
||||
}))
|
||||
}, [endDate, level, lineLimit, selectedSource, startDate, submittedSearch])
|
||||
}, [endDate, followEnabled, level, lineLimit, selectedSource, startDate, submittedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const filters = readUrlFilters(location.search)
|
||||
if (filters.selectedSource && filters.selectedSource !== selectedSource) setSelectedSource(filters.selectedSource)
|
||||
if (filters.lineLimit && filters.lineLimit !== lineLimit) setLineLimit(filters.lineLimit)
|
||||
if (filters.level && filters.level !== level) setLevel(filters.level)
|
||||
if ((filters.startDate || '') !== startDate) setStartDate(filters.startDate || '')
|
||||
if ((filters.endDate || '') !== endDate) setEndDate(filters.endDate || '')
|
||||
if (filters.searchQuery !== undefined && filters.searchQuery !== searchQuery) {
|
||||
setSearchQuery(filters.searchQuery)
|
||||
setSubmittedSearch(filters.searchQuery)
|
||||
}
|
||||
if (filters.follow !== followEnabled && new URLSearchParams(location.search).has('follow')) {
|
||||
setFollowEnabled(Boolean(filters.follow))
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const params = new URLSearchParams()
|
||||
if (selectedSource && selectedSource !== 'backend') params.set('source', selectedSource)
|
||||
if (lineLimit !== 200) params.set('limit', String(lineLimit))
|
||||
if (level !== 'all') params.set('level', level)
|
||||
if (startDate) params.set('start_date', startDate)
|
||||
if (endDate) params.set('end_date', endDate)
|
||||
if (submittedSearch.trim()) params.set('search', submittedSearch.trim())
|
||||
if (followEnabled) params.set('follow', '1')
|
||||
const nextSearch = params.toString()
|
||||
const currentSearch = location.search.replace(/^\?/, '')
|
||||
if (nextSearch !== currentSearch) {
|
||||
navigate({ pathname: location.pathname, search: nextSearch ? `?${nextSearch}` : '' }, { replace: true })
|
||||
}
|
||||
}, [endDate, followEnabled, level, lineLimit, location.pathname, location.search, navigate, selectedSource, startDate, submittedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!followEnabled || !followConnected || !isSuperAdmin || !selectedSource) return
|
||||
sendMessage({
|
||||
type: 'subscribe',
|
||||
data: {
|
||||
channel: LOG_TAIL_CHANNEL,
|
||||
source_id: selectedSource,
|
||||
limit: lineLimit,
|
||||
level,
|
||||
levels: level === 'all' ? undefined : level,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
search: submittedSearch.trim() || undefined,
|
||||
},
|
||||
})
|
||||
}, [endDate, followConnected, followEnabled, isSuperAdmin, level, lineLimit, selectedSource, sendMessage, startDate, submittedSearch])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollContainerRef.current
|
||||
if (!viewport || !shouldStickToBottomRef.current) return
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
}, [snapshot?.lines])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollContainerRef.current
|
||||
if (!viewport) return
|
||||
viewport.addEventListener('scroll', handleLogScroll, { passive: true })
|
||||
return () => viewport.removeEventListener('scroll', handleLogScroll)
|
||||
}, [snapshot?.source_id])
|
||||
|
||||
const resetFilters = () => {
|
||||
setLevel('all')
|
||||
@@ -188,6 +321,14 @@ export default function Logs() {
|
||||
setSearchQuery('')
|
||||
setSubmittedSearch('')
|
||||
setLineLimit(200)
|
||||
setFollowEnabled(false)
|
||||
}
|
||||
|
||||
const handleLogScroll = () => {
|
||||
const viewport = scrollContainerRef.current
|
||||
if (!viewport) return
|
||||
const distanceToBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight
|
||||
shouldStickToBottomRef.current = distanceToBottom < 32
|
||||
}
|
||||
|
||||
const copyLogs = async () => {
|
||||
@@ -217,6 +358,15 @@ export default function Logs() {
|
||||
<Button size="icon" variant="subtle" onClick={() => void fetchSources()} loading={sourcesLoading} aria-label="刷新日志源" title="刷新日志源">
|
||||
<RefreshCw size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={followEnabled ? 'primary' : 'subtle'}
|
||||
onClick={() => setFollowEnabled((enabled) => !enabled)}
|
||||
aria-label={followEnabled ? '暂停日志跟随' : '跟随日志'}
|
||||
title={followEnabled ? '暂停日志跟随' : '跟随日志'}
|
||||
>
|
||||
{followEnabled ? <Pause size={15} /> : <Play size={15} />}
|
||||
</Button>
|
||||
<Button size="icon" variant="primary" onClick={() => void fetchSnapshot()} loading={logLoading} aria-label="刷新日志" title="刷新日志">
|
||||
<RefreshCw size={15} />
|
||||
</Button>
|
||||
@@ -256,6 +406,7 @@ export default function Logs() {
|
||||
</div>
|
||||
<div className="an-toolbar">
|
||||
{snapshot ? <Badge tone="blue">{snapshot.line_count} 行</Badge> : null}
|
||||
{followEnabled ? <Badge tone={followConnected ? 'green' : 'amber'}>{followConnected ? '跟随中' : '连接中'}</Badge> : null}
|
||||
<Button size="icon" variant="subtle" onClick={copyLogs} disabled={!snapshot?.lines?.length} aria-label="复制日志" title="复制日志">
|
||||
<Copy size={15} />
|
||||
</Button>
|
||||
@@ -289,7 +440,7 @@ export default function Logs() {
|
||||
{logLoading ? (
|
||||
<div className="an-loading"><span className="an-spinner" />加载中</div>
|
||||
) : snapshot?.lines?.length ? (
|
||||
<Scrollbar className="an-log-reader__scroll">
|
||||
<Scrollbar className="an-log-reader__scroll" viewportRef={scrollContainerRef}>
|
||||
<pre>{snapshot.lines.join('\n')}</pre>
|
||||
</Scrollbar>
|
||||
) : (
|
||||
|
||||
@@ -801,7 +801,7 @@ const fieldLabels: Record<string, string> = {
|
||||
}
|
||||
|
||||
const fieldHelp: Record<string, string> = {
|
||||
demo_mode: '开启后访问 Earth 会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。',
|
||||
demo_mode: '开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。',
|
||||
}
|
||||
|
||||
function fieldLabel(key: string) {
|
||||
@@ -2977,7 +2977,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
toast({
|
||||
title: '数据库清理已入队',
|
||||
description: `任务 ${text(taskId, '-')} 会异步删除采集记录并刷新 Earth。`,
|
||||
description: `任务 ${text(taskId, '-')} 会异步删除采集记录并刷新智能星球。`,
|
||||
tone: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -3004,7 +3004,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
toast({
|
||||
title: '展示缓存清理已入队',
|
||||
description: `任务 ${text(taskId, '-')} 会异步清理缓存并刷新 Earth。`,
|
||||
description: `任务 ${text(taskId, '-')} 会异步清理缓存并刷新智能星球。`,
|
||||
tone: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -3519,9 +3519,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const response = await axios.get(apiPath('/system/cache/earth-layers'))
|
||||
replaceSelectedWithPayload('earth-layer-cache', 'Earth 图层缓存', [{
|
||||
replaceSelectedWithPayload('earth-layer-cache', '智能星球图层缓存', [{
|
||||
...response.data,
|
||||
__title: 'Earth 图层缓存',
|
||||
__title: '智能星球图层缓存',
|
||||
__module: '缓存',
|
||||
__status: '已读取',
|
||||
__metric: recordMetric(response.data),
|
||||
@@ -4624,14 +4624,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<Button variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} />上传</Button>
|
||||
<Button size="icon" variant="subtle" title="重置品牌配置" aria-label="重置品牌配置" onClick={() => setConfirmAction({
|
||||
title: '重置品牌配置',
|
||||
description: '确认恢复默认 Earth 品牌配置?当前自定义配置会被清空。',
|
||||
description: '确认恢复默认智能星球品牌配置?当前自定义配置会被清空。',
|
||||
danger: true,
|
||||
confirmLabel: '重置',
|
||||
run: () => requestAction('重置品牌配置', 'delete', '/earth/brand'),
|
||||
})} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||||
<Button size="icon" variant="danger" title="删除品牌配置" aria-label="删除品牌配置" onClick={() => setConfirmAction({
|
||||
title: '删除品牌配置',
|
||||
description: '确认删除 Earth 品牌配置?',
|
||||
description: '确认删除智能星球品牌配置?',
|
||||
danger: true,
|
||||
confirmLabel: '删除',
|
||||
run: () => requestAction('删除品牌配置', 'delete', '/earth/brand'),
|
||||
@@ -4641,7 +4641,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{config === configs.earthContent && activeSection.key === 'about' ? (
|
||||
<Button size="icon" variant="subtle" title="恢复默认关于信息" aria-label="恢复默认关于信息" onClick={() => setConfirmAction({
|
||||
title: '恢复默认关于信息',
|
||||
description: '确认恢复 Earth 关于卡片的默认内容?',
|
||||
description: '确认恢复智能星球关于卡片的默认内容?',
|
||||
danger: false,
|
||||
confirmLabel: '恢复',
|
||||
run: () => requestAction('恢复默认关于信息', 'delete', '/earth/about'),
|
||||
@@ -4877,7 +4877,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : <EmptyState title={isPlaceholderSection(activeSection.key) ? '后端能力未提供' : '暂无分组'} description={isPlaceholderSection(activeSection.key) ? '当前分区没有可用后端能力,Admin 不混入其他配置或假数据。' : '当前分区没有可配置项。'} />}
|
||||
) : <EmptyState title={isPlaceholderSection(activeSection.key) ? '后端能力未提供' : '暂无分组'} description={isPlaceholderSection(activeSection.key) ? '当前分区没有可用后端能力,控制台不混入其他配置或假数据。' : '当前分区没有可配置项。'} />}
|
||||
</Scrollbar>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -4923,7 +4923,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<Button key="task-status" size="icon" variant="subtle" icon="status" onClick={() => void loadTaskStatus(selected)} loading={actionLoading} title="任务状态" aria-label="任务状态" />,
|
||||
<Button key="clear-cache" size="icon" variant="subtle" onClick={() => setConfirmAction({
|
||||
title: '清理数据源缓存',
|
||||
description: `确认清理 ${recordTitle(selected)} 的 Earth 展示缓存?这不会删除数据库里的采集数据。`,
|
||||
description: `确认清理 ${recordTitle(selected)} 的智能星球展示缓存?这不会删除数据库里的采集数据。`,
|
||||
danger: false,
|
||||
confirmLabel: '清理缓存',
|
||||
run: () => clearDatasourceCache(selected),
|
||||
@@ -4933,7 +4933,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
) : (
|
||||
<Button key="delete-data" size="icon" variant="danger" onClick={() => setConfirmAction({
|
||||
title: '清理数据库数据',
|
||||
description: `确认删除 ${recordTitle(selected)} 的已采集数据库记录?这不会清理 Earth 展示缓存,此操作不可恢复。`,
|
||||
description: `确认删除 ${recordTitle(selected)} 的已采集数据库记录?这不会清理智能星球展示缓存,此操作不可恢复。`,
|
||||
danger: true,
|
||||
confirmLabel: '删除数据',
|
||||
run: () => clearDatasourceData(selected),
|
||||
@@ -5042,14 +5042,14 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<Button key="brand-upload-run" variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} />上传</Button>,
|
||||
<Button key="delete-brand" variant="danger" onClick={() => setConfirmAction({
|
||||
title: '删除品牌配置',
|
||||
description: '确认删除 Earth 品牌配置?',
|
||||
description: '确认删除智能星球品牌配置?',
|
||||
danger: true,
|
||||
confirmLabel: '删除',
|
||||
run: () => requestAction('删除品牌配置', 'delete', '/earth/brand'),
|
||||
})} loading={actionLoading}><Trash2 size={15} />删除</Button>,
|
||||
<Button key="reset-brand" variant="subtle" onClick={() => setConfirmAction({
|
||||
title: '重置品牌配置',
|
||||
description: '确认恢复默认 Earth 品牌配置?当前自定义配置会被清空。',
|
||||
description: '确认恢复默认智能星球品牌配置?当前自定义配置会被清空。',
|
||||
danger: true,
|
||||
confirmLabel: '重置',
|
||||
run: () => requestAction('重置品牌配置', 'delete', '/earth/brand'),
|
||||
@@ -5119,6 +5119,24 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
)
|
||||
}
|
||||
|
||||
const openLogsForTask = (item: Pick<CollectionQueueItem, 'taskId' | 'sourceId' | 'source'> & { requestId?: string | number | null }) => {
|
||||
const params = new URLSearchParams({ source: 'system-db' })
|
||||
const requestId = text(item.requestId, '')
|
||||
const taskId = text(item.taskId, '')
|
||||
const sourceId = text(item.sourceId, '')
|
||||
const source = text(item.source, '')
|
||||
if (requestId) {
|
||||
params.set('search', `request_id=${requestId}`)
|
||||
} else if (taskId) {
|
||||
params.set('search', `task_id=${taskId}`)
|
||||
} else if (sourceId) {
|
||||
params.set('search', `datasource_id=${sourceId}`)
|
||||
} else if (source) {
|
||||
params.set('search', source)
|
||||
}
|
||||
navigate(`/logs?${params.toString()}`)
|
||||
}
|
||||
|
||||
const renderModuleActions = () => {
|
||||
const actions: ReactNode[] = []
|
||||
if (config === configs.datasources) {
|
||||
@@ -5171,16 +5189,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}
|
||||
if (config === configs.earthContent) {
|
||||
actions.push(
|
||||
<Button key="cache-status" size="icon" variant="subtle" onClick={() => void loadEarthLayerCacheStatus()} loading={actionLoading} title="查看 Earth 图层缓存" aria-label="查看 Earth 图层缓存">
|
||||
<Button key="cache-status" size="icon" variant="subtle" onClick={() => void loadEarthLayerCacheStatus()} loading={actionLoading} title="查看智能星球图层缓存" aria-label="查看智能星球图层缓存">
|
||||
<DatabaseZap size={15} />
|
||||
</Button>,
|
||||
<Button key="cache-clear" size="icon" variant="danger" onClick={() => setConfirmAction({
|
||||
title: '清理 Earth 图层缓存',
|
||||
description: '确认清理 Earth 图层缓存?清理后下次访问会重新生成。',
|
||||
title: '清理智能星球图层缓存',
|
||||
description: '确认清理智能星球图层缓存?清理后下次访问会重新生成。',
|
||||
danger: true,
|
||||
confirmLabel: '清理',
|
||||
run: () => requestAction('清理 Earth 图层缓存', 'delete', '/system/cache/earth-layers', undefined, { refresh: false, successDescription: '缓存清理请求已提交,不会保存当前页面配置。' }),
|
||||
})} loading={actionLoading} title="清理 Earth 图层缓存" aria-label="清理 Earth 图层缓存">
|
||||
run: () => requestAction('清理智能星球图层缓存', 'delete', '/system/cache/earth-layers', undefined, { refresh: false, successDescription: '缓存清理请求已提交,不会保存当前页面配置。' }),
|
||||
})} loading={actionLoading} title="清理智能星球图层缓存" aria-label="清理智能星球图层缓存">
|
||||
<Trash2 size={15} />
|
||||
</Button>,
|
||||
)
|
||||
@@ -5255,7 +5273,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const source = text(selected.source || selected.collector_name, '')
|
||||
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || '') && isActiveQueueStatus(item.status))
|
||||
const status = queueItem?.status || datasourceStatus(selected)
|
||||
const taskId = queueItem?.taskId || selected.task_id
|
||||
const taskId = text(queueItem?.taskId || selected.task_id, '')
|
||||
return (
|
||||
<section className="an-task-summary">
|
||||
<div>
|
||||
@@ -5269,6 +5287,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<dt>进度</dt><dd>{queueItem ? `${queueProgress(queueItem)}%` : '-'}</dd>
|
||||
<dt>更新时间</dt><dd>{queueItem?.updatedAt ? new Date(queueItem.updatedAt).toLocaleTimeString() : text(selected.last_run_at, '-')}</dd>
|
||||
</dl>
|
||||
<div className="an-task-summary__actions">
|
||||
<Button size="sm" variant="subtle" onClick={() => openLogsForTask({ taskId, sourceId, source })}>
|
||||
<FileText size={14} />
|
||||
查看日志
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5396,6 +5420,9 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<Button size="icon" variant="subtle" title="查看数据源" aria-label="查看数据源" onClick={() => void jumpToQueueRecord(item)}>
|
||||
<Eye size={14} />
|
||||
</Button>
|
||||
<Button size="icon" variant="subtle" title="查看日志" aria-label="查看日志" onClick={() => openLogsForTask(item)}>
|
||||
<FileText size={14} />
|
||||
</Button>
|
||||
{item.status === 'failed' ? (
|
||||
<Button size="icon" variant="subtle" icon="trigger" title="重试" aria-label="重试" onClick={() => retryQueueItem(item)} />
|
||||
) : null}
|
||||
@@ -5800,10 +5827,10 @@ const configs = {
|
||||
detailTitle: 'AI 配置详情',
|
||||
},
|
||||
earthContent: {
|
||||
title: 'Earth 内容',
|
||||
description: '管理 Earth 品牌、边界、电视内容和内容资产。',
|
||||
listTitle: 'Earth 内容配置',
|
||||
listDescription: '只展示 Earth 品牌、边界构建和 TV 内容配置。',
|
||||
title: '智能星球内容',
|
||||
description: '管理智能星球品牌、边界、电视内容和内容资产。',
|
||||
listTitle: '智能星球内容配置',
|
||||
listDescription: '只展示智能星球品牌、边界构建和电视内容配置。',
|
||||
viewMode: 'management',
|
||||
sections: [
|
||||
{ key: 'brand', label: '品牌标识', url: '/earth/brand', map: (payload) => singleRow(payload, 'brand', { __title: '品牌配置', __module: '品牌' }).map((row) => ({ ...row, __title: '品牌配置', __module: '品牌', __status: '已读取' })) },
|
||||
@@ -5831,8 +5858,8 @@ const configs = {
|
||||
{ key: 'models_3d', label: '3D 模型', map: emptyRows },
|
||||
{ key: 'news_anchor_strategy', label: '新闻锚点策略', map: emptyRows },
|
||||
],
|
||||
actions: [makeAction('打开 Earth', <Globe2 size={15} />, '/earth')],
|
||||
detailTitle: 'Earth 配置详情',
|
||||
actions: [makeAction('打开智能星球', <Globe2 size={15} />, '/earth')],
|
||||
detailTitle: '智能星球配置详情',
|
||||
},
|
||||
collection: {
|
||||
title: '采集管理',
|
||||
|
||||
@@ -43,9 +43,9 @@ const roleOptions = [
|
||||
]
|
||||
|
||||
const gatekeeperOptions = [
|
||||
{ value: 'docs_user', label: 'Docs 用户文档' },
|
||||
{ value: 'docs_developer', label: 'Docs 开发文档' },
|
||||
{ value: 'docs_admin', label: 'Docs 管理/运维文档' },
|
||||
{ value: 'docs_user', label: '文档:用户文档' },
|
||||
{ value: 'docs_developer', label: '文档:开发文档' },
|
||||
{ value: 'docs_admin', label: '文档:管理/运维文档' },
|
||||
]
|
||||
|
||||
function roleTone(role: string) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { cn } from '../lib/utils'
|
||||
import { cn } from '../utils'
|
||||
|
||||
export function PageFrame({
|
||||
title,
|
||||
|
||||
@@ -39,8 +39,8 @@ export const adminRouteGroups: AdminRouteGroup[] = [
|
||||
|
||||
export const adminRoutes: AdminRouteItem[] = [
|
||||
{ path: '/admin', label: '仪表盘', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] },
|
||||
{ path: '/earth', label: 'Earth', group: 'overview', icon: Globe2, keywords: ['earth', '地球'] },
|
||||
{ path: '/docs', label: 'Docs', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
|
||||
{ path: '/earth', label: '智能星球', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] },
|
||||
{ path: '/docs', label: '文档', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
|
||||
{ path: '/datasources', label: '数据源', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] },
|
||||
{ path: '/data', label: '采集数据', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] },
|
||||
{ path: '/bgp', label: 'BGP观测', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] },
|
||||
@@ -48,7 +48,7 @@ export const adminRoutes: AdminRouteItem[] = [
|
||||
{ path: '/alerts/bgp', label: 'BGP 告警', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
|
||||
{ path: '/alerts/situational', label: '态势告警', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
|
||||
{ path: '/ai', label: 'AI', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
|
||||
{ path: '/earth-content', label: 'Earth 内容', group: 'ops', icon: Globe2, keywords: ['earth', 'tv', 'boundary', 'brand'] },
|
||||
{ path: '/earth-content', label: '智能星球内容', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand'] },
|
||||
{ path: '/collection-management', label: '采集管理', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
|
||||
{ path: '/logs', label: '系统日志', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
|
||||
{ path: '/users', label: '用户管理', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },
|
||||
|
||||
98
frontend/src/admin/runtimeLogs.ts
Normal file
98
frontend/src/admin/runtimeLogs.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const RECENT_EVENT_TTL_MS = 15_000
|
||||
const MAX_DETAIL_LENGTH = 4000
|
||||
const recentEventMap = new Map<string, number>()
|
||||
const NON_ADMIN_PATH_PREFIXES = ['/earth', '/docs', '/login', '/register', '/verify-email', '/forgot-password']
|
||||
|
||||
type RuntimeLogLevel = 'error' | 'warning' | 'info' | 'debug'
|
||||
|
||||
type AdminRuntimeLogInput = {
|
||||
level?: RuntimeLogLevel
|
||||
message: string
|
||||
category?: string
|
||||
module?: string
|
||||
detail?: unknown
|
||||
}
|
||||
|
||||
function normalizeErrorDetail(detail: unknown) {
|
||||
if (!detail) return ''
|
||||
if (detail instanceof Error) {
|
||||
return detail.stack || detail.message || String(detail)
|
||||
}
|
||||
if (typeof detail === 'string') return detail
|
||||
try {
|
||||
return JSON.stringify(detail)
|
||||
} catch {
|
||||
return String(detail)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkip(level: string, message: string, detail: string, category: string) {
|
||||
const key = `${level}::${category}::${message}::${detail}`
|
||||
const now = Date.now()
|
||||
const lastSeenAt = recentEventMap.get(key)
|
||||
recentEventMap.set(key, now)
|
||||
for (const [entryKey, entryTime] of recentEventMap.entries()) {
|
||||
if (now - entryTime > RECENT_EVENT_TTL_MS) {
|
||||
recentEventMap.delete(entryKey)
|
||||
}
|
||||
}
|
||||
return Boolean(lastSeenAt && now - lastSeenAt < RECENT_EVENT_TTL_MS)
|
||||
}
|
||||
|
||||
function isAdminRoute() {
|
||||
if (typeof window === 'undefined') return false
|
||||
const pathname = window.location.pathname
|
||||
return pathname !== '/' && !NON_ADMIN_PATH_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
|
||||
}
|
||||
|
||||
export async function reportAdminRuntimeLog({
|
||||
level = 'error',
|
||||
message,
|
||||
category = 'runtime',
|
||||
module = 'admin',
|
||||
detail = '',
|
||||
}: AdminRuntimeLogInput) {
|
||||
if (!message || typeof window === 'undefined' || !isAdminRoute()) return
|
||||
const normalizedDetail = normalizeErrorDetail(detail)
|
||||
if (shouldSkip(level, message, normalizedDetail, category)) return
|
||||
|
||||
try {
|
||||
await fetch('/api/v1/system/logs/admin-client', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
message,
|
||||
category,
|
||||
module,
|
||||
url: window.location.href,
|
||||
detail: normalizedDetail.slice(0, MAX_DETAIL_LENGTH),
|
||||
}),
|
||||
keepalive: true,
|
||||
})
|
||||
} catch {
|
||||
// Runtime log reporting must never create more runtime noise.
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAdminRuntimeErrorHandlers() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('error', (event) => {
|
||||
void reportAdminRuntimeLog({
|
||||
level: 'error',
|
||||
category: 'window-error',
|
||||
module: 'admin',
|
||||
message: event.message || '控制台发生未捕获错误',
|
||||
detail: event.error || `${event.filename || ''}:${event.lineno || 0}:${event.colno || 0}`,
|
||||
})
|
||||
})
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
void reportAdminRuntimeLog({
|
||||
level: 'error',
|
||||
category: 'unhandledrejection',
|
||||
module: 'admin',
|
||||
message: '控制台发生未处理 Promise 错误',
|
||||
detail: event.reason,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -77,7 +77,7 @@ const sectionTargets = [
|
||||
{ key: 'prompts', label: '提示词', terms: ['prompt', 'system prompt', 'AI prompts'] },
|
||||
{ key: 'playground', label: 'Playground', terms: ['playground', '测试', '对话'] },
|
||||
] },
|
||||
{ routePath: '/earth-content', routeLabel: 'Earth 内容', icon: Globe2, sections: [
|
||||
{ routePath: '/earth-content', routeLabel: '智能星球内容', icon: Globe2, sections: [
|
||||
{ key: 'brand', label: '品牌标识', terms: ['logo', '标题', 'subtitle'] },
|
||||
{ key: 'earth_assets', label: '国界精度', terms: ['boundary', 'PMTiles', '边界'] },
|
||||
{ key: 'tv', label: '电视直播', terms: ['TV', '直播源', '频道'] },
|
||||
@@ -115,7 +115,7 @@ const fieldTargets = [
|
||||
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'tools', sectionLabel: '工具调用', labels: ['搜索供应商', 'API 基础地址', 'WebSearch API Key', '最大结果数', '搜索深度', 'SerpAPI 引擎', 'SearXNG 分类', 'OCR 供应商', 'OCR API Key', '测试 Web Search 连通性'] },
|
||||
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'prompts', sectionLabel: '提示词', labels: ['System Prompt', '任务提示词', '重置 Prompt'] },
|
||||
{ routePath: '/collection-management', routeLabel: '采集管理', sectionKey: 'collector_credentials', sectionLabel: '采集器', labels: ['凭证教程', '生成凭证教程', '采集器配置', '映射模板', '目标 Schema'] },
|
||||
{ routePath: '/earth-content', routeLabel: 'Earth 内容', sectionKey: 'tv', sectionLabel: '电视直播', labels: ['默认频道', '自动回退', '直播源', '频道', '主页地址'] },
|
||||
{ routePath: '/earth-content', routeLabel: '智能星球内容', sectionKey: 'tv', sectionLabel: '电视直播', labels: ['默认频道', '自动回退', '直播源', '频道', '主页地址'] },
|
||||
{ routePath: '/settings', routeLabel: '设置', sectionKey: 'smtp', sectionLabel: 'SMTP 邮件', labels: ['主机', '端口', '用户名', '密码', '使用 TLS', '发件邮箱'] },
|
||||
]
|
||||
|
||||
@@ -200,7 +200,7 @@ const dynamicEndpoints = [
|
||||
{ routePath: '/users', routeLabel: '用户管理', icon: Users, sectionKey: 'users', sectionLabel: '用户', url: '/users' },
|
||||
{ routePath: '/admin', routeLabel: '仪表盘', icon: CircleGauge, sectionKey: 'overview', sectionLabel: '总览', url: '/health' },
|
||||
{ routePath: '/collection-management', routeLabel: '采集管理', icon: HardDrive, sectionKey: 'collector_credentials', sectionLabel: '采集器', url: '/datasources/configs/all' },
|
||||
{ routePath: '/earth-content', routeLabel: 'Earth 内容', icon: Globe2, sectionKey: 'tv', sectionLabel: '电视直播', url: '/settings/tv' },
|
||||
{ routePath: '/earth-content', routeLabel: '智能星球内容', icon: Globe2, sectionKey: 'tv', sectionLabel: '电视直播', url: '/settings/tv' },
|
||||
]
|
||||
|
||||
function flattenRecordValues(record: Record<string, unknown>, limit = 16) {
|
||||
|
||||
@@ -577,6 +577,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.an-task-summary__actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin__content {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
@@ -2455,34 +2461,64 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.an-badge--green,
|
||||
.an-status-pill--success {
|
||||
.an-badge--green {
|
||||
border-color: color-mix(in srgb, var(--an-success) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-success) 12%, var(--an-surface-alt));
|
||||
color: var(--an-success);
|
||||
}
|
||||
|
||||
.an-badge--amber,
|
||||
.an-status-pill--warning {
|
||||
.an-badge--amber {
|
||||
border-color: color-mix(in srgb, var(--an-warning) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-warning) 12%, var(--an-surface-alt));
|
||||
color: var(--an-warning);
|
||||
}
|
||||
|
||||
.an-badge--red,
|
||||
.an-status-pill--danger {
|
||||
.an-badge--red {
|
||||
border-color: color-mix(in srgb, var(--an-danger) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-danger) 12%, var(--an-surface-alt));
|
||||
color: var(--an-danger);
|
||||
}
|
||||
|
||||
.an-badge--blue,
|
||||
.an-badge--cyan,
|
||||
.an-badge--cyan {
|
||||
border-color: color-mix(in srgb, var(--an-info) 34%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-info) 12%, var(--an-surface-alt));
|
||||
color: var(--an-info);
|
||||
}
|
||||
|
||||
.an-badge--purple {
|
||||
border-color: color-mix(in srgb, #7c3aed 34%, var(--an-border));
|
||||
background: color-mix(in srgb, #7c3aed 12%, var(--an-surface-alt));
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.an-badge--slate {
|
||||
border-color: color-mix(in srgb, var(--an-muted) 28%, var(--an-border));
|
||||
background: color-mix(in srgb, var(--an-muted) 10%, var(--an-surface-alt));
|
||||
color: var(--an-muted);
|
||||
}
|
||||
|
||||
.an-status-pill--success {
|
||||
color: var(--an-success);
|
||||
}
|
||||
|
||||
.an-status-pill--warning {
|
||||
color: var(--an-warning);
|
||||
}
|
||||
|
||||
.an-status-pill--danger {
|
||||
color: var(--an-danger);
|
||||
}
|
||||
|
||||
.an-status-pill--info,
|
||||
.an-status-pill--running {
|
||||
color: var(--an-info);
|
||||
}
|
||||
|
||||
.an-badge--purple,
|
||||
.an-status-pill--ai {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.an-badge--slate,
|
||||
.an-status-pill--neutral {
|
||||
color: var(--an-muted);
|
||||
}
|
||||
@@ -3323,7 +3359,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
background: var(--an-surface-alt);
|
||||
}
|
||||
|
||||
.an-logs-source span {
|
||||
.an-logs-source > span:not(.an-status-pill) {
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
10
frontend/src/admin/utils.ts
Normal file
10
frontend/src/admin/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('zh-CN').format(value)
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { registerAdminRuntimeErrorHandlers } from './admin/runtimeLogs'
|
||||
import './index.css'
|
||||
|
||||
registerAdminRuntimeErrorHandlers()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
|
||||
@@ -31,7 +31,7 @@ export function AuthShell({ eyebrow, title, description, children, aside }: Auth
|
||||
<aside className="auth-shell__aside">
|
||||
{aside || (
|
||||
<>
|
||||
<span className="auth-shell__aside-kicker">Modern Admin</span>
|
||||
<span className="auth-shell__aside-kicker">现代控制台</span>
|
||||
<h2>把数据、告警、AI 和 Earth 运维放在同一个清爽工作台。</h2>
|
||||
<p>控制台默认进入现代化工作流,登录后直接使用 `/admin` 即可。</p>
|
||||
</>
|
||||
|
||||
@@ -310,10 +310,10 @@ export default function Docs() {
|
||||
<main className="docs-page" data-theme={effectiveTheme}>
|
||||
<aside className="docs-sidebar" aria-label="Documentation navigation">
|
||||
<Link className="docs-brand" to="/docs">
|
||||
<span className="docs-brand__mark">P</span>
|
||||
<span className="docs-brand__mark">智</span>
|
||||
<span>
|
||||
<span className="docs-brand__title">
|
||||
{lang === 'zh' ? '星球计划文档' : 'Planet Docs'}
|
||||
{lang === 'zh' ? '智能星球文档' : 'Intelligent Planet Docs'}
|
||||
</span>
|
||||
<span className="docs-brand__subtitle">
|
||||
{lang === 'zh' ? '开发者和用户手册' : 'Developer & User Guide'}
|
||||
@@ -385,7 +385,7 @@ export default function Docs() {
|
||||
<header className="docs-header">
|
||||
<div>
|
||||
<p className="docs-header__eyebrow">
|
||||
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : 'Docs'}
|
||||
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : lang === 'zh' ? '文档' : 'Docs'}
|
||||
</p>
|
||||
<h1 className="docs-header__title">
|
||||
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
|
||||
|
||||
@@ -38,7 +38,7 @@ const DOCS_GROUP_LABELS: Record<DocsLang, Record<DocsGroup, string>> = {
|
||||
Overview: '概览',
|
||||
Architecture: '业务架构',
|
||||
Manual: '使用手册',
|
||||
Earth: '地球可视化',
|
||||
Earth: '智能星球',
|
||||
Frontend: '前端',
|
||||
Backend: '后端',
|
||||
Agents: '智能体',
|
||||
@@ -49,7 +49,7 @@ const DOCS_GROUP_LABELS: Record<DocsLang, Record<DocsGroup, string>> = {
|
||||
Overview: 'Overview',
|
||||
Architecture: 'Architecture',
|
||||
Manual: 'Manual',
|
||||
Earth: 'Earth',
|
||||
Earth: 'Intelligent Planet',
|
||||
Frontend: 'Frontend',
|
||||
Backend: 'Backend',
|
||||
Agents: 'Agents',
|
||||
@@ -72,8 +72,8 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
en: { title: 'Quickstart', group: 'Manual', order: 2 },
|
||||
},
|
||||
'manual.md': {
|
||||
zh: { title: 'Planet 使用手册', group: 'Manual', order: 1 },
|
||||
en: { title: 'Planet Manual', group: 'Manual', order: 1 },
|
||||
zh: { title: '智能星球使用手册', group: 'Manual', order: 1 },
|
||||
en: { title: 'Intelligent Planet Manual', group: 'Manual', order: 1 },
|
||||
},
|
||||
'faq.md': {
|
||||
zh: { title: '常见问题', group: 'Manual', order: 3 },
|
||||
@@ -84,20 +84,20 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
en: { title: 'Business Architecture and Data Flows', group: 'Architecture', order: 5 },
|
||||
},
|
||||
'earth-frontend-context.md': {
|
||||
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },
|
||||
en: { title: 'Earth Frontend Context', group: 'Earth', order: 10 },
|
||||
zh: { title: '智能星球前端结构', group: 'Earth', order: 10 },
|
||||
en: { title: 'Intelligent Planet Frontend Context', group: 'Earth', order: 10 },
|
||||
},
|
||||
'earth-layer-style-reference.md': {
|
||||
zh: { title: 'Earth 图层样式属性索引', group: 'Earth', order: 11 },
|
||||
en: { title: 'Earth Layer Style Reference', group: 'Earth', order: 11 },
|
||||
zh: { title: '智能星球图层样式属性索引', group: 'Earth', order: 11 },
|
||||
en: { title: 'Intelligent Planet Layer Style Reference', group: 'Earth', order: 11 },
|
||||
},
|
||||
'earth-render-layer-order.md': {
|
||||
zh: { title: 'Earth 渲染图层顺序', group: 'Earth', order: 12 },
|
||||
en: { title: 'Earth Render Layer Order', group: 'Earth', order: 12 },
|
||||
zh: { title: '智能星球渲染图层顺序', group: 'Earth', order: 12 },
|
||||
en: { title: 'Intelligent Planet Render Layer Order', group: 'Earth', order: 12 },
|
||||
},
|
||||
'earth-satellite-footprint-policy.md': {
|
||||
zh: { title: 'Earth 卫星覆盖策略', group: 'Earth', order: 13 },
|
||||
en: { title: 'Earth Satellite Footprint Policy', group: 'Earth', order: 13 },
|
||||
zh: { title: '智能星球卫星覆盖策略', group: 'Earth', order: 13 },
|
||||
en: { title: 'Intelligent Planet Satellite Footprint Policy', group: 'Earth', order: 13 },
|
||||
},
|
||||
'earth-bgp-context.md': {
|
||||
zh: { title: 'BGP 态势上下文', group: 'Earth', order: 14 },
|
||||
@@ -108,12 +108,12 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
en: { title: 'News Live Streams Collector Format', group: 'Backend', order: 36 },
|
||||
},
|
||||
'earth-interactable-usage.md': {
|
||||
zh: { title: 'Earth 可交互图标接入', group: 'Earth', order: 16 },
|
||||
en: { title: 'Earth Interactable Usage', group: 'Earth', order: 16 },
|
||||
zh: { title: '智能星球可交互图标接入', group: 'Earth', order: 16 },
|
||||
en: { title: 'Intelligent Planet Interactable Usage', group: 'Earth', order: 16 },
|
||||
},
|
||||
'earth-toolbar-overlay-coordination.md': {
|
||||
zh: { title: 'Earth 工具栏与浮层协同', group: 'Earth', order: 17 },
|
||||
en: { title: 'Earth Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
|
||||
zh: { title: '智能星球工具栏与浮层协同', group: 'Earth', order: 17 },
|
||||
en: { title: 'Intelligent Planet Toolbar and Overlay Coordination', group: 'Earth', order: 17 },
|
||||
},
|
||||
'frontend-admin-frontend-context.md': {
|
||||
zh: { title: '控制台前端结构', group: 'Frontend', order: 20 },
|
||||
@@ -164,8 +164,8 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
|
||||
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
|
||||
},
|
||||
'ops-runbook.md': {
|
||||
zh: { title: 'Planet 运维手册', group: 'Ops', order: 49 },
|
||||
en: { title: 'Planet Ops Runbook', group: 'Ops', order: 49 },
|
||||
zh: { title: '智能星球运维手册', group: 'Ops', order: 49 },
|
||||
en: { title: 'Intelligent Planet Ops Runbook', group: 'Ops', order: 49 },
|
||||
},
|
||||
'ops-docker-compose-buildx-upgrade.md': {
|
||||
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
|
||||
|
||||
68
planet.sh
68
planet.sh
@@ -138,6 +138,7 @@ AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet-aiprovider:latest}"
|
||||
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
|
||||
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-$PLANET_STATE_DIR/aiprovider_runtime.env}"
|
||||
PLANET_EMPTY_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.empty.toml"
|
||||
PLANET_TUNA_UV_CONFIG_FILE="$PLANET_STATE_DIR/uv.tuna.toml"
|
||||
PLANET_UV_CONFIG_FILE="${PLANET_UV_CONFIG_FILE:-}"
|
||||
AI_PROVIDER_RECREATE_REQUIRED=0
|
||||
START_RUN_ACTIVE=0
|
||||
@@ -1111,55 +1112,94 @@ run_with_retry() {
|
||||
|
||||
run_uv_sync_with_mirror_fallback() {
|
||||
local log_file="$1"
|
||||
local lock_digest_before
|
||||
local sync_status
|
||||
|
||||
lock_digest_before="$(uv_lock_digest)"
|
||||
if run_with_retry \
|
||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||
"uv sync 默认源失败,已重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次,准备切换清华源" \
|
||||
"uv sync --frozen" \
|
||||
run_command_quiet_unless_verbose "$log_file" uv sync --frozen --group dev; then
|
||||
assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen"
|
||||
return 0
|
||||
fi
|
||||
assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen"
|
||||
|
||||
configure_uv_tuna_index
|
||||
set_wait_detail "已写入 uv.toml 清华源,重新执行 uv sync"
|
||||
set_wait_detail "已准备临时清华源配置,重新执行 uv sync"
|
||||
|
||||
lock_digest_before="$(uv_lock_digest)"
|
||||
run_with_retry \
|
||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||
"uv 环境初始化失败,清华源重试 ${DEPENDENCY_INSTALL_MAX_RETRIES} 次后仍失败" \
|
||||
"uv sync --frozen (清华源)" \
|
||||
run_uv_sync_with_tuna_config "$log_file"
|
||||
sync_status=$?
|
||||
assert_uv_lock_unchanged "$lock_digest_before" "uv sync --frozen (清华源)"
|
||||
return "$sync_status"
|
||||
}
|
||||
|
||||
run_uv_sync_with_tuna_config() {
|
||||
local log_file="$1"
|
||||
|
||||
UV_CONFIG_FILE="$PLANET_TUNA_UV_CONFIG_FILE" \
|
||||
run_command_quiet_unless_verbose "$log_file" uv sync --frozen --group dev
|
||||
}
|
||||
|
||||
configure_uv_tuna_index() {
|
||||
local uv_config="$SCRIPT_DIR/uv.toml"
|
||||
local uv_config="$PLANET_TUNA_UV_CONFIG_FILE"
|
||||
|
||||
if [ -f "$uv_config" ] &&
|
||||
grep -Fq 'name = "tsinghua"' "$uv_config" 2>/dev/null &&
|
||||
grep -Fq "$PLANET_UV_TUNA_INDEX_URL" "$uv_config" 2>/dev/null &&
|
||||
grep -Eq '^[[:space:]]*default[[:space:]]*=[[:space:]]*true' "$uv_config" 2>/dev/null; then
|
||||
log_note "uv.toml 已配置清华源,直接重试 uv sync"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -f "$uv_config" ] && grep -Eq '^[[:space:]]*default[[:space:]]*=[[:space:]]*true' "$uv_config" 2>/dev/null; then
|
||||
log_warn "uv.toml 已存在默认 index,未覆盖用户配置"
|
||||
log_note "如需手动切换清华源,可添加 [[index]] name=\"tsinghua\" 并设置 default=true"
|
||||
log_note "临时 uv 清华源配置已存在,直接重试 uv sync"
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
if [ -s "$uv_config" ]; then
|
||||
printf "\n"
|
||||
fi
|
||||
printf '[[index]]\n'
|
||||
printf 'name = "tsinghua"\n'
|
||||
printf 'url = "%s"\n' "$PLANET_UV_TUNA_INDEX_URL"
|
||||
printf 'default = true\n'
|
||||
} >> "$uv_config"
|
||||
} > "$uv_config"
|
||||
|
||||
log_note "已向 uv.toml 添加清华 PyPI 源: ${PLANET_UV_TUNA_INDEX_URL}"
|
||||
chmod 600 "$uv_config" 2>/dev/null || true
|
||||
log_note "已写入临时 uv 清华源配置: ${uv_config}"
|
||||
}
|
||||
|
||||
uv_lock_digest() {
|
||||
local lock_file="$SCRIPT_DIR/uv.lock"
|
||||
|
||||
if [ ! -f "$lock_file" ]; then
|
||||
printf "missing\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$lock_file" | awk '{print $1}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
cksum "$lock_file" | awk '{print $1 ":" $2}'
|
||||
}
|
||||
|
||||
assert_uv_lock_unchanged() {
|
||||
local expected_digest="$1"
|
||||
local action_label="$2"
|
||||
local current_digest
|
||||
|
||||
current_digest="$(uv_lock_digest)"
|
||||
if [ "$current_digest" = "$expected_digest" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_error "${action_label} 修改了 uv.lock;新环境依赖安装不允许污染 lockfile"
|
||||
log_note "请还原 uv.lock,并只在明确升级依赖时手动运行 uv lock"
|
||||
exit 1
|
||||
}
|
||||
|
||||
install_system_package() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.66.1"
|
||||
version = "0.67.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user