Compare commits

17 Commits
main ... dev

Author SHA1 Message Date
linkong
2015ab79bd feat: enrich earth bgp event visualization 2026-03-27 17:26:17 +08:00
linkong
755729ee5e fix: make earth satellite and cable toggles fully unload 2026-03-27 17:11:07 +08:00
linkong
7a3ca6e1b3 fix: refine treemap sizing and add earth bgp collectors 2026-03-27 16:35:40 +08:00
linkong
62f2d9f403 fix: polish earth legend and info panel interactions 2026-03-27 16:01:12 +08:00
linkong
b448a1e560 docs: refresh quick start commands 2026-03-27 15:30:08 +08:00
linkong
2cc0c9412c fix: narrow vite dep scan entries 2026-03-27 15:13:36 +08:00
linkong
3dd210a3e5 feat: refine collected data overview and admin navigation 2026-03-27 15:08:45 +08:00
linkong
a761dfc5fb style: refine earth legend item presentation 2026-03-27 14:30:28 +08:00
linkong
7ec9586f7a chore: add earth hud backup and icon assets 2026-03-27 14:30:12 +08:00
linkong
b0058edf17 feat: add bgp observability and admin ui improvements 2026-03-27 14:27:07 +08:00
linkong
bf2c4a172d fix: upgrade startup script controls 2026-03-27 11:13:01 +08:00
linkong
30a29a6e34 fix: redesign earth hud interactions and legend behavior 2026-03-26 17:58:03 +08:00
linkong
ab09f0ba78 fix: polish earth toolbar controls and loading copy 2026-03-26 14:04:57 +08:00
linkong
7b53cf9a06 Enhance Earth interaction and bump version to 0.21.0 2026-03-26 11:09:57 +08:00
linkong
a04f4f9e67 Bump version to 0.20.0 and add changelog 2026-03-26 10:41:46 +08:00
linkong
ce5feba3b9 Stabilize Earth module and fix satellite TLE handling 2026-03-26 10:29:50 +08:00
linkong
3fd6cbb6f7 Add version history and bump project version to 0.19.0 2026-03-25 17:36:18 +08:00
101 changed files with 9636 additions and 1719 deletions

View File

@@ -184,14 +184,20 @@
## 快速启动
```bash
# 启动全部服务
docker-compose up -d
# 启动前后端服务
./planet.sh start
# 仅启后端
cd backend && python -m uvicorn app.main:app --reload
# 仅启后端
./planet.sh restart -b
# 仅启前端
cd frontend && npm run dev
# 仅启前端
./planet.sh restart -f
# 交互创建用户
./planet.sh createuser
# 查看服务状态
./planet.sh health
```
## API 文档

4
TODO.md Normal file
View File

@@ -0,0 +1,4 @@
# TODO
- [ ] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点
- [ ] 开始做 BGP 异常和海缆/区域的关联展示

1
VERSION Normal file
View File

@@ -0,0 +1 @@
0.21.8

View File

@@ -11,6 +11,7 @@ from app.api.v1 import (
settings,
collected_data,
visualization,
bgp,
)
api_router = APIRouter()
@@ -27,3 +28,4 @@ api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboar
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends
@@ -68,7 +68,7 @@ async def acknowledge_alert(
alert.status = AlertStatus.ACKNOWLEDGED
alert.acknowledged_by = current_user.id
alert.acknowledged_at = datetime.utcnow()
alert.acknowledged_at = datetime.now(UTC)
await db.commit()
return {"message": "Alert acknowledged", "alert": alert.to_dict()}
@@ -89,7 +89,7 @@ async def resolve_alert(
alert.status = AlertStatus.RESOLVED
alert.resolved_by = current_user.id
alert.resolved_at = datetime.utcnow()
alert.resolved_at = datetime.now(UTC)
alert.resolution_notes = resolution
await db.commit()

182
backend/app/api/v1/bgp.py Normal file
View File

@@ -0,0 +1,182 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.db.session import get_db
from app.models.bgp_anomaly import BGPAnomaly
from app.models.collected_data import CollectedData
from app.models.user import User
router = APIRouter()
BGP_SOURCES = ("ris_live_bgp", "bgpstream_bgp")
def _parse_dt(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _matches_time(value: Optional[datetime], time_from: Optional[datetime], time_to: Optional[datetime]) -> bool:
if value is None:
return False
if time_from and value < time_from:
return False
if time_to and value > time_to:
return False
return True
@router.get("/events")
async def list_bgp_events(
prefix: Optional[str] = Query(None),
origin_asn: Optional[int] = Query(None),
peer_asn: Optional[int] = Query(None),
collector: Optional[str] = Query(None),
event_type: Optional[str] = Query(None),
source: Optional[str] = Query(None),
time_from: Optional[str] = Query(None),
time_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(CollectedData)
.where(CollectedData.source.in_(BGP_SOURCES))
.order_by(CollectedData.reference_date.desc().nullslast(), CollectedData.id.desc())
)
if source:
stmt = stmt.where(CollectedData.source == source)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
filtered = []
for record in records:
metadata = record.extra_data or {}
if prefix and metadata.get("prefix") != prefix:
continue
if origin_asn is not None and metadata.get("origin_asn") != origin_asn:
continue
if peer_asn is not None and metadata.get("peer_asn") != peer_asn:
continue
if collector and metadata.get("collector") != collector:
continue
if event_type and metadata.get("event_type") != event_type:
continue
if (dt_from or dt_to) and not _matches_time(record.reference_date, dt_from, dt_to):
continue
filtered.append(record)
offset = (page - 1) * page_size
return {
"total": len(filtered),
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in filtered[offset : offset + page_size]],
}
@router.get("/events/{event_id}")
async def get_bgp_event(
event_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
record = await db.get(CollectedData, event_id)
if not record or record.source not in BGP_SOURCES:
raise HTTPException(status_code=404, detail="BGP event not found")
return record.to_dict()
@router.get("/anomalies")
async def list_bgp_anomalies(
severity: Optional[str] = Query(None),
anomaly_type: Optional[str] = Query(None),
status: Optional[str] = Query(None),
prefix: Optional[str] = Query(None),
origin_asn: Optional[int] = Query(None),
time_from: Optional[str] = Query(None),
time_to: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc(), BGPAnomaly.id.desc())
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if anomaly_type:
stmt = stmt.where(BGPAnomaly.anomaly_type == anomaly_type)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
if prefix:
stmt = stmt.where(BGPAnomaly.prefix == prefix)
if origin_asn is not None:
stmt = stmt.where(BGPAnomaly.origin_asn == origin_asn)
result = await db.execute(stmt)
records = result.scalars().all()
dt_from = _parse_dt(time_from)
dt_to = _parse_dt(time_to)
if dt_from or dt_to:
records = [record for record in records if _matches_time(record.created_at, dt_from, dt_to)]
offset = (page - 1) * page_size
return {
"total": len(records),
"page": page,
"page_size": page_size,
"data": [record.to_dict() for record in records[offset : offset + page_size]],
}
@router.get("/anomalies/summary")
async def get_bgp_anomaly_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
total_result = await db.execute(select(func.count(BGPAnomaly.id)))
type_result = await db.execute(
select(BGPAnomaly.anomaly_type, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.anomaly_type)
.order_by(func.count(BGPAnomaly.id).desc())
)
severity_result = await db.execute(
select(BGPAnomaly.severity, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.severity)
.order_by(func.count(BGPAnomaly.id).desc())
)
status_result = await db.execute(
select(BGPAnomaly.status, func.count(BGPAnomaly.id))
.group_by(BGPAnomaly.status)
.order_by(func.count(BGPAnomaly.id).desc())
)
return {
"total": total_result.scalar() or 0,
"by_type": {row[0]: row[1] for row in type_result.fetchall()},
"by_severity": {row[0]: row[1] for row in severity_result.fetchall()},
"by_status": {row[0]: row[1] for row in status_result.fetchall()},
}
@router.get("/anomalies/{anomaly_id}")
async def get_bgp_anomaly(
anomaly_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
record = await db.get(BGPAnomaly, anomaly_id)
if not record:
raise HTTPException(status_code=404, detail="BGP anomaly not found")
return record.to_dict()

View File

@@ -9,10 +9,12 @@ import io
from app.core.collected_data_fields import get_metadata_field
from app.core.countries import COUNTRY_OPTIONS, get_country_search_variants, normalize_country
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.user import User
from app.core.security import get_current_user
from app.models.collected_data import CollectedData
from app.models.datasource import DataSource
router = APIRouter()
@@ -100,11 +102,13 @@ def build_search_rank_sql(search: Optional[str]) -> str:
"""
def serialize_collected_row(row) -> dict:
def serialize_collected_row(row, source_name_map: dict[str, str] | None = None) -> dict:
metadata = row[7]
source = row[1]
return {
"id": row[0],
"source": row[1],
"source": source,
"source_name": source_name_map.get(source, source) if source_name_map else source,
"source_id": row[2],
"data_type": row[3],
"name": row[4],
@@ -121,12 +125,17 @@ def serialize_collected_row(row) -> dict:
"rmax": get_metadata_field(metadata, "rmax"),
"rpeak": get_metadata_field(metadata, "rpeak"),
"power": get_metadata_field(metadata, "power"),
"collected_at": row[8].isoformat() if row[8] else None,
"reference_date": row[9].isoformat() if row[9] else None,
"collected_at": to_iso8601_utc(row[8]),
"reference_date": to_iso8601_utc(row[9]),
"is_valid": row[10],
}
async def get_source_name_map(db: AsyncSession) -> dict[str, str]:
result = await db.execute(select(DataSource.source, DataSource.name))
return {row[0]: row[1] for row in result.fetchall()}
@router.get("")
async def list_collected_data(
mode: str = Query("current", description="查询模式: current/history"),
@@ -188,10 +197,11 @@ async def list_collected_data(
result = await db.execute(query, params)
rows = result.fetchall()
source_name_map = await get_source_name_map(db)
data = []
for row in rows:
data.append(serialize_collected_row(row[:11]))
data.append(serialize_collected_row(row[:11], source_name_map))
return {
"total": total,
@@ -204,23 +214,38 @@ async def list_collected_data(
@router.get("/summary")
async def get_data_summary(
mode: str = Query("current", description="查询模式: current/history"),
source: Optional[str] = Query(None, description="数据源过滤"),
data_type: Optional[str] = Query(None, description="数据类型过滤"),
country: Optional[str] = Query(None, description="国家过滤"),
search: Optional[str] = Query(None, description="搜索名称"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取数据汇总统计"""
where_sql = "WHERE COALESCE(is_current, TRUE) = TRUE" if mode != "history" else ""
where_sql, params = build_where_clause(source, data_type, country, search)
if mode != "history":
where_sql = f"({where_sql}) AND COALESCE(is_current, TRUE) = TRUE"
overall_where_sql = "COALESCE(is_current, TRUE) = TRUE" if mode != "history" else "1=1"
overall_total_result = await db.execute(
text(f"SELECT COUNT(*) FROM collected_data WHERE {overall_where_sql}")
)
overall_total = overall_total_result.scalar() or 0
# By source and data_type
result = await db.execute(
text("""
text(f"""
SELECT source, data_type, COUNT(*) as count
FROM collected_data
""" + where_sql + """
WHERE {where_sql}
GROUP BY source, data_type
ORDER BY source, data_type
""")
"""),
params,
)
rows = result.fetchall()
source_name_map = await get_source_name_map(db)
by_source = {}
total = 0
@@ -229,27 +254,56 @@ async def get_data_summary(
data_type = row[1]
count = row[2]
if source not in by_source:
by_source[source] = {}
by_source[source][data_type] = count
source_key = source_name_map.get(source, source)
if source_key not in by_source:
by_source[source_key] = {}
by_source[source_key][data_type] = count
total += count
# Total by source
source_totals = await db.execute(
text("""
text(f"""
SELECT source, COUNT(*) as count
FROM collected_data
""" + where_sql + """
WHERE {where_sql}
GROUP BY source
ORDER BY count DESC
""")
"""),
params,
)
source_rows = source_totals.fetchall()
type_totals = await db.execute(
text(f"""
SELECT data_type, COUNT(*) as count
FROM collected_data
WHERE {where_sql}
GROUP BY data_type
ORDER BY count DESC, data_type
"""),
params,
)
type_rows = type_totals.fetchall()
return {
"total_records": total,
"overall_total_records": overall_total,
"by_source": by_source,
"source_totals": [{"source": row[0], "count": row[1]} for row in source_rows],
"source_totals": [
{
"source": row[0],
"source_name": source_name_map.get(row[0], row[0]),
"count": row[1],
}
for row in source_rows
],
"type_totals": [
{
"data_type": row[0],
"count": row[1],
}
for row in type_rows
],
}
@@ -269,9 +323,13 @@ async def get_data_sources(
""")
)
rows = result.fetchall()
source_name_map = await get_source_name_map(db)
return {
"sources": [row[0] for row in rows],
"sources": [
{"source": row[0], "source_name": source_name_map.get(row[0], row[0])}
for row in rows
],
}
@@ -334,7 +392,8 @@ async def get_collected_data(
detail="数据不存在",
)
return serialize_collected_row(row)
source_name_map = await get_source_name_map(db)
return serialize_collected_row(row, source_name_map)
def build_where_clause(
@@ -482,8 +541,8 @@ async def export_csv(
get_metadata_field(row[7], "value"),
get_metadata_field(row[7], "unit"),
json.dumps(row[7]) if row[7] else "",
row[8].isoformat() if row[8] else "",
row[9].isoformat() if row[9] else "",
to_iso8601_utc(row[8]) or "",
to_iso8601_utc(row[9]) or "",
row[10],
]
)

View File

@@ -1,6 +1,6 @@
"""Dashboard API with caching and optimizations"""
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,6 +13,7 @@ from app.models.alert import Alert, AlertSeverity
from app.models.task import CollectionTask
from app.core.security import get_current_user
from app.core.cache import cache
from app.core.time import to_iso8601_utc
# Built-in collectors info (mirrored from datasources.py)
@@ -111,7 +112,7 @@ async def get_stats(
if cached_result:
return cached_result
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
# Count built-in collectors
built_in_count = len(COLLECTOR_INFO)
@@ -175,7 +176,7 @@ async def get_stats(
"active_datasources": active_datasources,
"tasks_today": tasks_today,
"success_rate": round(success_rate, 1),
"last_updated": datetime.utcnow().isoformat(),
"last_updated": to_iso8601_utc(datetime.now(UTC)),
"alerts": {
"critical": critical_alerts,
"warning": warning_alerts,
@@ -230,10 +231,10 @@ async def get_summary(
summary[module] = {
"datasources": data["datasources"],
"total_records": 0, # Built-in don't track this in dashboard stats
"last_updated": datetime.utcnow().isoformat(),
"last_updated": to_iso8601_utc(datetime.now(UTC)),
}
response = {"modules": summary, "last_updated": datetime.utcnow().isoformat()}
response = {"modules": summary, "last_updated": to_iso8601_utc(datetime.now(UTC))}
cache.set(cache_key, response, expire_seconds=300)

View File

@@ -14,6 +14,7 @@ from app.models.user import User
from app.models.datasource_config import DataSourceConfig
from app.core.security import get_current_user
from app.core.cache import cache
from app.core.time import to_iso8601_utc
router = APIRouter()
@@ -123,8 +124,8 @@ async def list_configs(
"headers": c.headers,
"config": c.config,
"is_active": c.is_active,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
"created_at": to_iso8601_utc(c.created_at),
"updated_at": to_iso8601_utc(c.updated_at),
}
for c in configs
],
@@ -155,8 +156,8 @@ async def get_config(
"headers": config.headers,
"config": config.config,
"is_active": config.is_active,
"created_at": config.created_at.isoformat() if config.created_at else None,
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
"created_at": to_iso8601_utc(config.created_at),
"updated_at": to_iso8601_utc(config.updated_at),
}

View File

@@ -1,9 +1,12 @@
import asyncio
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
from app.core.security import get_current_user
from app.core.data_sources import get_data_sources_config
from app.db.session import get_db
@@ -24,6 +27,12 @@ def format_frequency_label(minutes: int) -> str:
return f"{minutes}m"
def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
if datasource.last_run_at is None:
return True
return datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes) <= now
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
datasource = None
try:
@@ -47,6 +56,7 @@ async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optio
select(CollectionTask)
.where(CollectionTask.datasource_id == datasource_id)
.where(CollectionTask.completed_at.isnot(None))
.where(CollectionTask.status.in_(("success", "failed", "cancelled")))
.order_by(CollectionTask.completed_at.desc())
.limit(1)
)
@@ -94,9 +104,9 @@ async def list_datasources(
)
data_count = data_count_result.scalar() or 0
last_run = None
if last_task and last_task.completed_at and data_count > 0:
last_run = last_task.completed_at.strftime("%Y-%m-%d %H:%M")
last_run_at = datasource.last_run_at or (last_task.completed_at if last_task else None)
last_run = to_iso8601_utc(last_run_at)
last_status = datasource.last_status or (last_task.status if last_task else None)
collector_list.append(
{
@@ -110,6 +120,10 @@ async def list_datasources(
"collector_class": datasource.collector_class,
"endpoint": endpoint,
"last_run": last_run,
"last_run_at": to_iso8601_utc(last_run_at),
"last_status": last_status,
"last_records_processed": last_task.records_processed if last_task else None,
"data_count": data_count,
"is_running": running_task is not None,
"task_id": running_task.id if running_task else None,
"progress": running_task.progress if running_task else None,
@@ -122,6 +136,105 @@ async def list_datasources(
return {"total": len(collector_list), "data": collector_list}
@router.post("/trigger-all")
async def trigger_all_datasources(
force: bool = Query(False),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(DataSource)
.where(DataSource.is_active == True)
.order_by(DataSource.module, DataSource.id)
)
datasources = result.scalars().all()
if not datasources:
return {
"status": "noop",
"message": "No active data sources to trigger",
"triggered": [],
"skipped": [],
"failed": [],
}
previous_task_ids: dict[int, Optional[int]] = {}
triggered_sources: list[dict] = []
skipped_sources: list[dict] = []
failed_sources: list[dict] = []
now = datetime.now(timezone.utc)
for datasource in datasources:
running_task = await get_running_task(db, datasource.id)
if running_task is not None:
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "already_running",
"task_id": running_task.id,
}
)
continue
if not force and not is_due_for_collection(datasource, now):
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "within_frequency_window",
"last_run_at": to_iso8601_utc(datasource.last_run_at),
"next_run_at": to_iso8601_utc(
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
),
}
)
continue
previous_task_ids[datasource.id] = await get_latest_task_id_for_datasource(datasource.id)
success = run_collector_now(datasource.source)
if not success:
failed_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "trigger_failed",
}
)
continue
triggered_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"task_id": None,
}
)
for _ in range(20):
await asyncio.sleep(0.1)
pending = [item for item in triggered_sources if item["task_id"] is None]
if not pending:
break
for item in pending:
task_id = await get_latest_task_id_for_datasource(item["id"])
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
item["task_id"] = task_id
return {
"status": "triggered" if triggered_sources else "partial",
"message": f"Triggered {len(triggered_sources)} data sources",
"force": force,
"triggered": triggered_sources,
"skipped": skipped_sources,
"failed": failed_sources,
}
@router.get("/{source_id}")
async def get_datasource(
source_id: str,
@@ -217,15 +330,19 @@ async def trigger_datasource(
if not datasource.is_active:
raise HTTPException(status_code=400, detail="Data source is disabled")
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
success = run_collector_now(datasource.source)
if not success:
raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'")
task_id = None
for _ in range(10):
for _ in range(20):
await asyncio.sleep(0.1)
task_id = await get_latest_task_id_for_datasource(datasource.id)
if task_id is not None:
if task_id is not None and task_id != previous_task_id:
break
if task_id == previous_task_id:
task_id = None
return {
"status": "triggered",

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -7,6 +7,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import get_current_user
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.datasource import DataSource
from app.models.system_setting import SystemSetting
@@ -114,9 +115,9 @@ def serialize_collector(datasource: DataSource) -> dict:
"frequency_minutes": datasource.frequency_minutes,
"frequency": format_frequency_label(datasource.frequency_minutes),
"is_active": datasource.is_active,
"last_run_at": datasource.last_run_at.isoformat() if datasource.last_run_at else None,
"last_run_at": to_iso8601_utc(datasource.last_run_at),
"last_status": datasource.last_status,
"next_run_at": datasource.next_run_at.isoformat() if datasource.next_run_at else None,
"next_run_at": to_iso8601_utc(datasource.next_run_at),
}
@@ -216,5 +217,5 @@ async def get_all_settings(
"notifications": await get_setting_payload(db, "notifications"),
"security": await get_setting_payload(db, "security"),
"collectors": [serialize_collector(datasource) for datasource in datasources],
"generated_at": datetime.utcnow().isoformat() + "Z",
}
"generated_at": to_iso8601_utc(datetime.now(UTC)),
}

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
@@ -8,6 +8,7 @@ from sqlalchemy import text
from app.db.session import get_db
from app.models.user import User
from app.core.security import get_current_user
from app.core.time import to_iso8601_utc
from app.services.collectors.registry import collector_registry
@@ -61,8 +62,8 @@ async def list_tasks(
"datasource_id": t[1],
"datasource_name": t[2],
"status": t[3],
"started_at": t[4].isoformat() if t[4] else None,
"completed_at": t[5].isoformat() if t[5] else None,
"started_at": to_iso8601_utc(t[4]),
"completed_at": to_iso8601_utc(t[5]),
"records_processed": t[6],
"error_message": t[7],
}
@@ -100,8 +101,8 @@ async def get_task(
"datasource_id": task[1],
"datasource_name": task[2],
"status": task[3],
"started_at": task[4].isoformat() if task[4] else None,
"completed_at": task[5].isoformat() if task[5] else None,
"started_at": to_iso8601_utc(task[4]),
"completed_at": to_iso8601_utc(task[5]),
"records_processed": task[6],
"error_message": task[7],
}
@@ -147,8 +148,8 @@ async def trigger_collection(
"status": result.get("status", "unknown"),
"records_processed": result.get("records_processed", 0),
"error_message": result.get("error"),
"started_at": datetime.utcnow(),
"completed_at": datetime.utcnow(),
"started_at": datetime.now(UTC),
"completed_at": datetime.now(UTC),
},
)

View File

@@ -4,16 +4,20 @@ Unified API for all visualization data sources.
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
"""
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import List, Dict, Any, Optional
from app.core.collected_data_fields import get_record_field
from app.core.satellite_tle import build_tle_lines_from_elements
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.bgp_anomaly import BGPAnomaly
from app.models.collected_data import CollectedData
from app.services.cable_graph import build_graph_from_data, CableGraph
from app.services.collectors.bgp_common import RIPE_RIS_COLLECTOR_COORDS
router = APIRouter()
@@ -155,6 +159,20 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
if not norad_id:
continue
tle_line1 = metadata.get("tle_line1")
tle_line2 = metadata.get("tle_line2")
if not tle_line1 or not tle_line2:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=norad_id,
epoch=metadata.get("epoch"),
inclination=metadata.get("inclination"),
raan=metadata.get("raan"),
eccentricity=metadata.get("eccentricity"),
arg_of_perigee=metadata.get("arg_of_perigee"),
mean_anomaly=metadata.get("mean_anomaly"),
mean_motion=metadata.get("mean_motion"),
)
features.append(
{
"type": "Feature",
@@ -174,6 +192,8 @@ def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]
"mean_motion": metadata.get("mean_motion"),
"bstar": metadata.get("bstar"),
"classification_type": metadata.get("classification_type"),
"tle_line1": tle_line1,
"tle_line2": tle_line2,
"data_type": "satellite_tle",
},
}
@@ -256,6 +276,131 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
return {"type": "FeatureCollection", "features": features}
def convert_bgp_anomalies_to_geojson(records: List[BGPAnomaly]) -> Dict[str, Any]:
features = []
for record in records:
evidence = record.evidence or {}
collectors = evidence.get("collectors") or record.peer_scope or []
if not collectors:
nested = evidence.get("events") or []
collectors = [
str((item or {}).get("collector") or "").strip()
for item in nested
if (item or {}).get("collector")
]
collectors = [collector for collector in collectors if collector]
if not collectors:
collectors = []
collector = collectors[0] if collectors else None
location = None
if collector:
location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector))
if location is None:
nested = evidence.get("events") or []
for item in nested:
collector_name = (item or {}).get("collector")
if collector_name and collector_name in RIPE_RIS_COLLECTOR_COORDS:
location = RIPE_RIS_COLLECTOR_COORDS[collector_name]
collector = collector_name
break
if location is None:
continue
as_path = []
if isinstance(evidence.get("as_path"), list):
as_path = evidence.get("as_path") or []
if not as_path:
nested = evidence.get("events") or []
for item in nested:
candidate_path = (item or {}).get("as_path")
if isinstance(candidate_path, list) and candidate_path:
as_path = candidate_path
break
impacted_regions = []
seen_regions = set()
for collector_name in collectors:
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(str(collector_name))
if not collector_location:
continue
region_key = (
collector_location.get("country"),
collector_location.get("city"),
)
if region_key in seen_regions:
continue
seen_regions.add(region_key)
impacted_regions.append(
{
"collector": collector_name,
"country": collector_location.get("country"),
"city": collector_location.get("city"),
"latitude": collector_location.get("latitude"),
"longitude": collector_location.get("longitude"),
}
)
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [location["longitude"], location["latitude"]],
},
"properties": {
"id": record.id,
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"source": record.source,
"anomaly_type": record.anomaly_type,
"severity": record.severity,
"status": record.status,
"prefix": record.prefix,
"origin_asn": record.origin_asn,
"new_origin_asn": record.new_origin_asn,
"collectors": collectors,
"collector_count": len(collectors) or 1,
"as_path": as_path,
"impacted_regions": impacted_regions,
"confidence": record.confidence,
"summary": record.summary,
"created_at": to_iso8601_utc(record.created_at),
},
}
)
return {"type": "FeatureCollection", "features": features}
def convert_bgp_collectors_to_geojson() -> Dict[str, Any]:
features = []
for collector, location in sorted(RIPE_RIS_COLLECTOR_COORDS.items()):
features.append(
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [location["longitude"], location["latitude"]],
},
"properties": {
"collector": collector,
"city": location.get("city"),
"country": location.get("country"),
"status": "online",
},
}
)
return {"type": "FeatureCollection", "features": features}
# ============== API Endpoints ==============
@@ -383,7 +528,11 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)):
@router.get("/geo/satellites")
async def get_satellites_geojson(
limit: int = 10000,
limit: Optional[int] = Query(
None,
ge=1,
description="Maximum number of satellites to return. Omit for no limit.",
),
db: AsyncSession = Depends(get_db),
):
"""获取卫星 TLE GeoJSON 数据"""
@@ -392,8 +541,9 @@ async def get_satellites_geojson(
.where(CollectedData.source == "celestrak_tle")
.where(CollectedData.name != "Unknown")
.order_by(CollectedData.id.desc())
.limit(limit)
)
if limit is not None:
stmt = stmt.limit(limit)
result = await db.execute(stmt)
records = result.scalars().all()
@@ -457,6 +607,31 @@ async def get_gpu_clusters_geojson(
}
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
stmt = select(BGPAnomaly).order_by(BGPAnomaly.created_at.desc()).limit(limit)
if severity:
stmt = stmt.where(BGPAnomaly.severity == severity)
if status:
stmt = stmt.where(BGPAnomaly.status == status)
result = await db.execute(stmt)
records = list(result.scalars().all())
geojson = convert_bgp_anomalies_to_geojson(records)
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/geo/bgp-collectors")
async def get_bgp_collectors_geojson():
geojson = convert_bgp_collectors_to_geojson()
return {**geojson, "count": len(geojson.get("features", []))}
@router.get("/all")
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
"""获取所有可视化数据的统一端点
@@ -527,7 +702,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
)
return {
"generated_at": datetime.utcnow().isoformat() + "Z",
"generated_at": to_iso8601_utc(datetime.now(UTC)),
"version": "1.0",
"data": {
"satellites": satellites,

View File

@@ -3,13 +3,14 @@
import asyncio
import json
import logging
from datetime import datetime
from datetime import UTC, datetime
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from jose import jwt, JWTError
from app.core.config import settings
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
logger = logging.getLogger(__name__)
@@ -59,6 +60,7 @@ async def websocket_endpoint(
"ixp_nodes",
"alerts",
"dashboard",
"datasource_tasks",
],
},
}
@@ -72,7 +74,7 @@ async def websocket_endpoint(
await websocket.send_json(
{
"type": "heartbeat",
"data": {"action": "pong", "timestamp": datetime.utcnow().isoformat()},
"data": {"action": "pong", "timestamp": to_iso8601_utc(datetime.now(UTC))},
}
)
elif data.get("type") == "subscribe":

View File

@@ -6,9 +6,16 @@ import os
from pydantic_settings import BaseSettings
ROOT_DIR = Path(__file__).parent.parent.parent.parent
VERSION_FILE = ROOT_DIR / "VERSION"
class Settings(BaseSettings):
PROJECT_NAME: str = "Intelligent Planet Plan"
VERSION: str = "1.0.0"
VERSION: str = (
os.getenv("APP_VERSION")
or (VERSION_FILE.read_text(encoding="utf-8").strip() if VERSION_FILE.exists() else "0.19.0")
)
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"

View File

@@ -23,6 +23,8 @@ COLLECTOR_URL_KEYS = {
"top500": "top500.url",
"epoch_ai_gpu": "epoch_ai.gpu_clusters_url",
"spacetrack_tle": "spacetrack.tle_query_url",
"ris_live_bgp": "ris_live.url",
"bgpstream_bgp": "bgpstream.url",
}

View File

@@ -37,3 +37,9 @@ epoch_ai:
spacetrack:
base_url: "https://www.space-track.org"
tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json"
ris_live:
url: "https://ris-live.ripe.net/v1/stream/?format=json&client=planet-ris-live"
bgpstream:
url: "https://broker.bgpstream.caida.org/v2"

View File

@@ -120,6 +120,20 @@ DEFAULT_DATASOURCES = {
"priority": "P2",
"frequency_minutes": 1440,
},
"ris_live_bgp": {
"id": 21,
"name": "RIPE RIS Live BGP",
"module": "L3",
"priority": "P1",
"frequency_minutes": 15,
},
"bgpstream_bgp": {
"id": 22,
"name": "CAIDA BGPStream Backfill",
"module": "L3",
"priority": "P1",
"frequency_minutes": 360,
},
}
ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()}

View File

@@ -0,0 +1,116 @@
"""Helpers for building stable TLE lines from orbital elements."""
from __future__ import annotations
from datetime import datetime
from typing import Any, Optional
def compute_tle_checksum(line: str) -> str:
"""Compute the standard modulo-10 checksum for a TLE line."""
total = 0
for char in line[:68]:
if char.isdigit():
total += int(char)
elif char == "-":
total += 1
return str(total % 10)
def _parse_epoch(value: Any) -> Optional[datetime]:
if not value:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
return None
def build_tle_line1(norad_cat_id: Any, epoch: Any) -> Optional[str]:
"""Build a valid TLE line 1 from the NORAD id and epoch."""
epoch_date = _parse_epoch(epoch)
if not norad_cat_id or epoch_date is None:
return None
epoch_year = epoch_date.year % 100
start_of_year = datetime(epoch_date.year, 1, 1, tzinfo=epoch_date.tzinfo)
day_of_year = (epoch_date - start_of_year).days + 1
ms_of_day = (
epoch_date.hour * 3600000
+ epoch_date.minute * 60000
+ epoch_date.second * 1000
+ int(epoch_date.microsecond / 1000)
)
day_fraction = ms_of_day / 86400000
decimal_fraction = f"{day_fraction:.8f}"[1:]
epoch_str = f"{epoch_year:02d}{day_of_year:03d}{decimal_fraction}"
core = (
f"1 {int(norad_cat_id):05d}U 00001A {epoch_str}"
" .00000000 00000-0 00000-0 0 999"
)
return core + compute_tle_checksum(core)
def build_tle_line2(
norad_cat_id: Any,
inclination: Any,
raan: Any,
eccentricity: Any,
arg_of_perigee: Any,
mean_anomaly: Any,
mean_motion: Any,
) -> Optional[str]:
"""Build a valid TLE line 2 from the standard orbital elements."""
required = [
norad_cat_id,
inclination,
raan,
eccentricity,
arg_of_perigee,
mean_anomaly,
mean_motion,
]
if any(value is None for value in required):
return None
eccentricity_digits = str(round(float(eccentricity) * 10_000_000)).zfill(7)
core = (
f"2 {int(norad_cat_id):05d}"
f" {float(inclination):8.4f}"
f" {float(raan):8.4f}"
f" {eccentricity_digits}"
f" {float(arg_of_perigee):8.4f}"
f" {float(mean_anomaly):8.4f}"
f" {float(mean_motion):11.8f}"
"00000"
)
return core + compute_tle_checksum(core)
def build_tle_lines_from_elements(
*,
norad_cat_id: Any,
epoch: Any,
inclination: Any,
raan: Any,
eccentricity: Any,
arg_of_perigee: Any,
mean_anomaly: Any,
mean_motion: Any,
) -> tuple[Optional[str], Optional[str]]:
"""Build both TLE lines from a metadata payload."""
line1 = build_tle_line1(norad_cat_id, epoch)
line2 = build_tle_line2(
norad_cat_id,
inclination,
raan,
eccentricity,
arg_of_perigee,
mean_anomaly,
mean_motion,
)
return line1, line2

View File

@@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Optional
import bcrypt
@@ -49,9 +49,9 @@ def get_password_hash(password: str) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = datetime.now(UTC) + expires_delta
elif settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
else:
expire = None
if expire:
@@ -65,7 +65,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
if settings.REFRESH_TOKEN_EXPIRE_DAYS > 0:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
expire = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire})
to_encode.update({"type": "refresh"})
if "sub" in to_encode:

20
backend/app/core/time.py Normal file
View File

@@ -0,0 +1,20 @@
"""Time helpers for API serialization."""
from __future__ import annotations
from datetime import UTC, datetime
def ensure_utc(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def to_iso8601_utc(value: datetime | None) -> str | None:
normalized = ensure_utc(value)
if normalized is None:
return None
return normalized.isoformat().replace("+00:00", "Z")

View File

@@ -1,9 +1,10 @@
"""Data broadcaster for WebSocket connections"""
import asyncio
from datetime import datetime
from datetime import UTC, datetime
from typing import Dict, Any, Optional
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
@@ -22,7 +23,7 @@ class DataBroadcaster:
"active_datasources": 8,
"tasks_today": 45,
"success_rate": 97.8,
"last_updated": datetime.utcnow().isoformat(),
"last_updated": to_iso8601_utc(datetime.now(UTC)),
"alerts": {"critical": 0, "warning": 2, "info": 5},
}
@@ -35,7 +36,7 @@ class DataBroadcaster:
{
"type": "data_frame",
"channel": "dashboard",
"timestamp": datetime.utcnow().isoformat(),
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": {"stats": stats},
},
channel="dashboard",
@@ -49,7 +50,7 @@ class DataBroadcaster:
await manager.broadcast(
{
"type": "alert_notification",
"timestamp": datetime.utcnow().isoformat(),
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"data": {"alert": alert},
}
)
@@ -60,7 +61,7 @@ class DataBroadcaster:
{
"type": "data_frame",
"channel": "gpu_clusters",
"timestamp": datetime.utcnow().isoformat(),
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data,
}
)
@@ -71,12 +72,24 @@ class DataBroadcaster:
{
"type": "data_frame",
"channel": channel,
"timestamp": datetime.utcnow().isoformat(),
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data,
},
channel=channel if channel in manager.active_connections else "all",
)
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
"""Broadcast datasource task progress updates to connected clients."""
await manager.broadcast(
{
"type": "data_frame",
"channel": "datasource_tasks",
"timestamp": to_iso8601_utc(datetime.now(UTC)),
"payload": data,
},
channel="all",
)
def start(self):
"""Start all broadcasters"""
if not self.running:

View File

@@ -60,6 +60,28 @@ async def seed_default_datasources(session: AsyncSession):
await session.commit()
async def ensure_default_admin_user(session: AsyncSession):
from app.core.security import get_password_hash
from app.models.user import User
result = await session.execute(
text("SELECT id FROM users WHERE username = 'admin'")
)
if result.fetchone():
return
session.add(
User(
username="admin",
email="admin@planet.local",
password_hash=get_password_hash("admin123"),
role="super_admin",
is_active=True,
)
)
await session.commit()
async def init_db():
import app.models.user # noqa: F401
import app.models.gpu_cluster # noqa: F401
@@ -68,6 +90,7 @@ async def init_db():
import app.models.datasource # noqa: F401
import app.models.datasource_config # noqa: F401
import app.models.alert # noqa: F401
import app.models.bgp_anomaly # noqa: F401
import app.models.collected_data # noqa: F401
import app.models.system_setting # noqa: F401
@@ -125,3 +148,4 @@ async def init_db():
async with async_session_factory() as session:
await seed_default_datasources(session)
await ensure_default_admin_user(session)

View File

@@ -5,6 +5,7 @@ from app.models.data_snapshot import DataSnapshot
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.alert import Alert, AlertSeverity, AlertStatus
from app.models.bgp_anomaly import BGPAnomaly
from app.models.system_setting import SystemSetting
__all__ = [
@@ -18,4 +19,5 @@ __all__ = [
"Alert",
"AlertSeverity",
"AlertStatus",
]
"BGPAnomaly",
]

View File

@@ -5,6 +5,7 @@ from typing import Optional
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Enum as SQLEnum
from sqlalchemy.orm import relationship
from app.core.time import to_iso8601_utc
from app.db.session import Base
@@ -50,8 +51,8 @@ class Alert(Base):
"acknowledged_by": self.acknowledged_by,
"resolved_by": self.resolved_by,
"resolution_notes": self.resolution_notes,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None,
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"created_at": to_iso8601_utc(self.created_at),
"updated_at": to_iso8601_utc(self.updated_at),
"acknowledged_at": to_iso8601_utc(self.acknowledged_at),
"resolved_at": to_iso8601_utc(self.resolved_at),
}

View File

@@ -0,0 +1,58 @@
"""BGP anomaly model for derived routing intelligence."""
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text
from app.core.time import to_iso8601_utc
from app.db.session import Base
class BGPAnomaly(Base):
__tablename__ = "bgp_anomalies"
id = Column(Integer, primary_key=True, index=True)
snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True)
task_id = Column(Integer, ForeignKey("collection_tasks.id"), nullable=True, index=True)
source = Column(String(100), nullable=False, index=True)
anomaly_type = Column(String(50), nullable=False, index=True)
severity = Column(String(20), nullable=False, index=True)
status = Column(String(20), nullable=False, default="active", index=True)
entity_key = Column(String(255), nullable=False, index=True)
prefix = Column(String(64), nullable=True, index=True)
origin_asn = Column(Integer, nullable=True, index=True)
new_origin_asn = Column(Integer, nullable=True, index=True)
peer_scope = Column(JSON, default=list)
started_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
ended_at = Column(DateTime(timezone=True), nullable=True)
confidence = Column(Float, nullable=False, default=0.5)
summary = Column(Text, nullable=False)
evidence = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow, index=True)
__table_args__ = (
Index("idx_bgp_anomalies_source_created", "source", "created_at"),
Index("idx_bgp_anomalies_type_status", "anomaly_type", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"snapshot_id": self.snapshot_id,
"task_id": self.task_id,
"source": self.source,
"anomaly_type": self.anomaly_type,
"severity": self.severity,
"status": self.status,
"entity_key": self.entity_key,
"prefix": self.prefix,
"origin_asn": self.origin_asn,
"new_origin_asn": self.new_origin_asn,
"peer_scope": self.peer_scope or [],
"started_at": to_iso8601_utc(self.started_at),
"ended_at": to_iso8601_utc(self.ended_at),
"confidence": self.confidence,
"summary": self.summary,
"evidence": self.evidence or {},
"created_at": to_iso8601_utc(self.created_at),
}

View File

@@ -4,6 +4,7 @@ from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, T
from sqlalchemy.sql import func
from app.core.collected_data_fields import get_record_field
from app.core.time import to_iso8601_utc
from app.db.session import Base
@@ -74,15 +75,11 @@ class CollectedData(Base):
"value": get_record_field(self, "value"),
"unit": get_record_field(self, "unit"),
"metadata": self.extra_data,
"collected_at": self.collected_at.isoformat()
if self.collected_at is not None
else None,
"reference_date": self.reference_date.isoformat()
if self.reference_date is not None
else None,
"collected_at": to_iso8601_utc(self.collected_at),
"reference_date": to_iso8601_utc(self.reference_date),
"is_current": self.is_current,
"previous_record_id": self.previous_record_id,
"change_type": self.change_type,
"change_summary": self.change_summary,
"deleted_at": self.deleted_at.isoformat() if self.deleted_at is not None else None,
"deleted_at": to_iso8601_utc(self.deleted_at),
}

View File

@@ -30,6 +30,8 @@ from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector
from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector
from app.services.collectors.spacetrack import SpaceTrackTLECollector
from app.services.collectors.celestrak import CelesTrakTLECollector
from app.services.collectors.ris_live import RISLiveCollector
from app.services.collectors.bgpstream import BGPStreamBackfillCollector
collector_registry.register(TOP500Collector())
collector_registry.register(EpochAIGPUCollector())
@@ -51,3 +53,5 @@ collector_registry.register(ArcGISLandingPointCollector())
collector_registry.register(ArcGISCableLandingRelationCollector())
collector_registry.register(SpaceTrackTLECollector())
collector_registry.register(CelesTrakTLECollector())
collector_registry.register(RISLiveCollector())
collector_registry.register(BGPStreamBackfillCollector())

View File

@@ -5,7 +5,7 @@ Collects submarine cable data from ArcGIS GeoJSON API.
import json
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
import httpx
from app.services.collectors.base import BaseCollector
@@ -84,7 +84,7 @@ class ArcGISCableCollector(BaseCollector):
"color": props.get("color"),
"route_coordinates": route_coordinates,
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, TypeError, KeyError):

View File

@@ -1,5 +1,5 @@
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
import httpx
from app.services.collectors.base import BaseCollector
@@ -67,7 +67,7 @@ class ArcGISLandingPointCollector(BaseCollector):
"status": props.get("status"),
"landing_point_id": props.get("landing_point_id"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, TypeError, KeyError):

View File

@@ -1,5 +1,5 @@
import asyncio
from datetime import datetime
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional
import httpx
@@ -143,7 +143,7 @@ class ArcGISCableLandingRelationCollector(BaseCollector):
"facility": facility,
"status": status,
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, TypeError, KeyError):

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
from datetime import datetime
from datetime import UTC, datetime
import httpx
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.collected_data_fields import build_dynamic_metadata, get_record_field
from app.core.config import settings
from app.core.countries import normalize_country
from app.core.time import to_iso8601_utc
from app.core.websocket.broadcaster import broadcaster
class BaseCollector(ABC):
@@ -20,12 +22,14 @@ class BaseCollector(ABC):
module: str = "L1"
frequency_hours: int = 4
data_type: str = "generic"
fail_on_empty: bool = False
def __init__(self):
self._current_task = None
self._db_session = None
self._datasource_id = 1
self._resolved_url: Optional[str] = None
self._last_broadcast_progress: Optional[int] = None
async def resolve_url(self, db: AsyncSession) -> None:
from app.core.data_sources import get_data_sources_config
@@ -33,18 +37,53 @@ class BaseCollector(ABC):
config = get_data_sources_config()
self._resolved_url = await config.get_url(self.name, db)
def update_progress(self, records_processed: int):
async def _publish_task_update(self, force: bool = False):
if not self._current_task:
return
progress = float(self._current_task.progress or 0.0)
rounded_progress = int(round(progress))
if not force and self._last_broadcast_progress == rounded_progress:
return
await broadcaster.broadcast_datasource_task_update(
{
"datasource_id": getattr(self, "_datasource_id", None),
"collector_name": self.name,
"task_id": self._current_task.id,
"status": self._current_task.status,
"phase": self._current_task.phase,
"progress": progress,
"records_processed": self._current_task.records_processed,
"total_records": self._current_task.total_records,
"started_at": to_iso8601_utc(self._current_task.started_at),
"completed_at": to_iso8601_utc(self._current_task.completed_at),
"error_message": self._current_task.error_message,
}
)
self._last_broadcast_progress = rounded_progress
async def update_progress(self, records_processed: int, *, commit: bool = False, force: bool = False):
"""Update task progress - call this during data processing"""
if self._current_task and self._db_session and self._current_task.total_records > 0:
if self._current_task and self._db_session:
self._current_task.records_processed = records_processed
self._current_task.progress = (
records_processed / self._current_task.total_records
) * 100
if self._current_task.total_records and self._current_task.total_records > 0:
self._current_task.progress = (
records_processed / self._current_task.total_records
) * 100
else:
self._current_task.progress = 0.0
if commit:
await self._db_session.commit()
await self._publish_task_update(force=force)
async def set_phase(self, phase: str):
if self._current_task and self._db_session:
self._current_task.phase = phase
await self._db_session.commit()
await self._publish_task_update(force=True)
@abstractmethod
async def fetch(self) -> List[Dict[str, Any]]:
@@ -133,7 +172,7 @@ class BaseCollector(ABC):
from app.models.task import CollectionTask
from app.models.data_snapshot import DataSnapshot
start_time = datetime.utcnow()
start_time = datetime.now(UTC)
datasource_id = getattr(self, "_datasource_id", 1)
snapshot_id: Optional[int] = None
@@ -152,14 +191,20 @@ class BaseCollector(ABC):
self._current_task = task
self._db_session = db
self._last_broadcast_progress = None
await self.resolve_url(db)
await self._publish_task_update(force=True)
try:
await self.set_phase("fetching")
raw_data = await self.fetch()
task.total_records = len(raw_data)
await db.commit()
await self._publish_task_update(force=True)
if self.fail_on_empty and not raw_data:
raise RuntimeError(f"Collector {self.name} returned no data")
await self.set_phase("transforming")
data = self.transform(raw_data)
@@ -172,33 +217,35 @@ class BaseCollector(ABC):
task.phase = "completed"
task.records_processed = records_count
task.progress = 100.0
task.completed_at = datetime.utcnow()
task.completed_at = datetime.now(UTC)
await db.commit()
await self._publish_task_update(force=True)
return {
"status": "success",
"task_id": task_id,
"records_processed": records_count,
"execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(),
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
}
except Exception as e:
task.status = "failed"
task.phase = "failed"
task.error_message = str(e)
task.completed_at = datetime.utcnow()
task.completed_at = datetime.now(UTC)
if snapshot_id is not None:
snapshot = await db.get(DataSnapshot, snapshot_id)
if snapshot:
snapshot.status = "failed"
snapshot.completed_at = datetime.utcnow()
snapshot.completed_at = datetime.now(UTC)
snapshot.summary = {"error": str(e)}
await db.commit()
await self._publish_task_update(force=True)
return {
"status": "failed",
"task_id": task_id,
"error": str(e),
"execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(),
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
}
async def _save_data(
@@ -219,11 +266,11 @@ class BaseCollector(ABC):
snapshot.record_count = 0
snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0}
snapshot.status = "success"
snapshot.completed_at = datetime.utcnow()
snapshot.completed_at = datetime.now(UTC)
await db.commit()
return 0
collected_at = datetime.utcnow()
collected_at = datetime.now(UTC)
records_added = 0
created_count = 0
updated_count = 0
@@ -329,8 +376,7 @@ class BaseCollector(ABC):
records_added += 1
if i % 100 == 0:
self.update_progress(i + 1)
await db.commit()
await self.update_progress(i + 1, commit=True)
if snapshot_id is not None:
deleted_keys = previous_current_keys - seen_entity_keys
@@ -350,7 +396,7 @@ class BaseCollector(ABC):
if snapshot:
snapshot.record_count = records_added
snapshot.status = "success"
snapshot.completed_at = datetime.utcnow()
snapshot.completed_at = datetime.now(UTC)
snapshot.summary = {
"created": created_count,
"updated": updated_count,
@@ -359,7 +405,7 @@ class BaseCollector(ABC):
}
await db.commit()
self.update_progress(len(data))
await self.update_progress(len(data), force=True)
return records_added
async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int:
@@ -406,8 +452,8 @@ async def log_task(
status=status,
records_processed=records_processed,
error_message=error_message,
started_at=datetime.utcnow(),
completed_at=datetime.utcnow(),
started_at=datetime.now(UTC),
completed_at=datetime.now(UTC),
)
db.add(task)
await db.commit()

View File

@@ -0,0 +1,313 @@
"""Shared helpers for BGP collectors."""
from __future__ import annotations
import hashlib
import ipaddress
from collections import Counter, defaultdict
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.bgp_anomaly import BGPAnomaly
from app.models.collected_data import CollectedData
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {
"rrc00": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
"rrc01": {"city": "London", "country": "United Kingdom", "latitude": 51.5072, "longitude": -0.1276},
"rrc03": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
"rrc04": {"city": "Geneva", "country": "Switzerland", "latitude": 46.2044, "longitude": 6.1432},
"rrc05": {"city": "Vienna", "country": "Austria", "latitude": 48.2082, "longitude": 16.3738},
"rrc06": {"city": "Otemachi", "country": "Japan", "latitude": 35.686, "longitude": 139.7671},
"rrc07": {"city": "Stockholm", "country": "Sweden", "latitude": 59.3293, "longitude": 18.0686},
"rrc10": {"city": "Milan", "country": "Italy", "latitude": 45.4642, "longitude": 9.19},
"rrc11": {"city": "New York", "country": "United States", "latitude": 40.7128, "longitude": -74.006},
"rrc12": {"city": "Frankfurt", "country": "Germany", "latitude": 50.1109, "longitude": 8.6821},
"rrc13": {"city": "Moscow", "country": "Russia", "latitude": 55.7558, "longitude": 37.6173},
"rrc14": {"city": "Palo Alto", "country": "United States", "latitude": 37.4419, "longitude": -122.143},
"rrc15": {"city": "Sao Paulo", "country": "Brazil", "latitude": -23.5558, "longitude": -46.6396},
"rrc16": {"city": "Miami", "country": "United States", "latitude": 25.7617, "longitude": -80.1918},
"rrc18": {"city": "Barcelona", "country": "Spain", "latitude": 41.3874, "longitude": 2.1686},
"rrc19": {"city": "Johannesburg", "country": "South Africa", "latitude": -26.2041, "longitude": 28.0473},
"rrc20": {"city": "Zurich", "country": "Switzerland", "latitude": 47.3769, "longitude": 8.5417},
"rrc21": {"city": "Paris", "country": "France", "latitude": 48.8566, "longitude": 2.3522},
"rrc22": {"city": "Bucharest", "country": "Romania", "latitude": 44.4268, "longitude": 26.1025},
"rrc23": {"city": "Singapore", "country": "Singapore", "latitude": 1.3521, "longitude": 103.8198},
"rrc24": {"city": "Montevideo", "country": "Uruguay", "latitude": -34.9011, "longitude": -56.1645},
"rrc25": {"city": "Amsterdam", "country": "Netherlands", "latitude": 52.3676, "longitude": 4.9041},
"rrc26": {"city": "Dubai", "country": "United Arab Emirates", "latitude": 25.2048, "longitude": 55.2708},
}
def _safe_int(value: Any) -> int | None:
try:
if value in (None, ""):
return None
return int(value)
except (TypeError, ValueError):
return None
def _parse_timestamp(value: Any) -> datetime:
if isinstance(value, datetime):
return value.astimezone(UTC) if value.tzinfo else value.replace(tzinfo=UTC)
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=UTC)
if isinstance(value, str) and value:
normalized = value.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
return parsed.astimezone(UTC) if parsed.tzinfo else parsed.replace(tzinfo=UTC)
return datetime.now(UTC)
def _normalize_as_path(raw_path: Any) -> list[int]:
if raw_path in (None, ""):
return []
if isinstance(raw_path, list):
return [asn for asn in (_safe_int(item) for item in raw_path) if asn is not None]
if isinstance(raw_path, str):
parts = raw_path.replace("{", "").replace("}", "").split()
return [asn for asn in (_safe_int(item) for item in parts) if asn is not None]
return []
def normalize_bgp_event(payload: dict[str, Any], *, project: str) -> dict[str, Any]:
raw_message = payload.get("raw_message", payload)
raw_path = (
payload.get("path")
or payload.get("as_path")
or payload.get("attrs", {}).get("path")
or payload.get("attrs", {}).get("as_path")
or []
)
as_path = _normalize_as_path(raw_path)
raw_type = str(payload.get("event_type") or payload.get("type") or payload.get("msg_type") or "").lower()
if raw_type in {"a", "announce", "announcement"}:
event_type = "announcement"
elif raw_type in {"w", "withdraw", "withdrawal"}:
event_type = "withdrawal"
elif raw_type in {"r", "rib"}:
event_type = "rib"
else:
event_type = raw_type or "announcement"
prefix = str(payload.get("prefix") or payload.get("prefixes") or payload.get("target_prefix") or "").strip()
if prefix.startswith("[") and prefix.endswith("]"):
prefix = prefix[1:-1]
timestamp = _parse_timestamp(payload.get("timestamp") or payload.get("time") or payload.get("ts"))
collector = str(payload.get("collector") or payload.get("host") or payload.get("router") or "unknown")
peer_asn = _safe_int(payload.get("peer_asn") or payload.get("peer"))
origin_asn = _safe_int(payload.get("origin_asn")) or (as_path[-1] if as_path else None)
source_material = "|".join(
[
collector,
str(peer_asn or ""),
prefix,
event_type,
timestamp.isoformat(),
",".join(str(asn) for asn in as_path),
]
)
source_id = hashlib.sha1(source_material.encode("utf-8")).hexdigest()[:24]
prefix_length = None
is_more_specific = False
if prefix:
try:
network = ipaddress.ip_network(prefix, strict=False)
prefix_length = int(network.prefixlen)
is_more_specific = prefix_length > (24 if network.version == 4 else 48)
except ValueError:
prefix_length = None
collector_location = RIPE_RIS_COLLECTOR_COORDS.get(collector, {})
metadata = {
"project": project,
"collector": collector,
"peer_asn": peer_asn,
"peer_ip": payload.get("peer_ip") or payload.get("peer_address"),
"event_type": event_type,
"prefix": prefix,
"origin_asn": origin_asn,
"as_path": as_path,
"communities": payload.get("communities") or payload.get("attrs", {}).get("communities") or [],
"next_hop": payload.get("next_hop") or payload.get("attrs", {}).get("next_hop"),
"med": payload.get("med") or payload.get("attrs", {}).get("med"),
"local_pref": payload.get("local_pref") or payload.get("attrs", {}).get("local_pref"),
"timestamp": timestamp.isoformat(),
"as_path_length": len(as_path),
"prefix_length": prefix_length,
"is_more_specific": is_more_specific,
"visibility_weight": 1,
"collector_location": collector_location,
"raw_message": raw_message,
}
return {
"source_id": source_id,
"name": prefix or f"{collector}:{event_type}",
"title": f"{event_type} {prefix}".strip(),
"description": f"{collector} observed {event_type} for {prefix}".strip(),
"reference_date": timestamp.isoformat(),
"country": collector_location.get("country"),
"city": collector_location.get("city"),
"latitude": collector_location.get("latitude"),
"longitude": collector_location.get("longitude"),
"metadata": metadata,
}
async def create_bgp_anomalies_for_batch(
db: AsyncSession,
*,
source: str,
snapshot_id: int | None,
task_id: int | None,
events: list[dict[str, Any]],
) -> int:
if not events:
return 0
pending_anomalies: list[BGPAnomaly] = []
prefix_to_origins: defaultdict[str, set[int]] = defaultdict(set)
prefix_to_more_specifics: defaultdict[str, list[dict[str, Any]]] = defaultdict(list)
withdrawal_counter: Counter[tuple[str, int | None]] = Counter()
prefixes = {event["metadata"].get("prefix") for event in events if event.get("metadata", {}).get("prefix")}
previous_origin_map: dict[str, set[int]] = defaultdict(set)
if prefixes:
previous_query = await db.execute(
select(CollectedData).where(
CollectedData.source == source,
CollectedData.snapshot_id != snapshot_id,
CollectedData.extra_data["prefix"].as_string().in_(sorted(prefixes)),
)
)
for record in previous_query.scalars().all():
metadata = record.extra_data or {}
prefix = metadata.get("prefix")
origin = _safe_int(metadata.get("origin_asn"))
if prefix and origin is not None:
previous_origin_map[prefix].add(origin)
for event in events:
metadata = event.get("metadata", {})
prefix = metadata.get("prefix")
origin_asn = _safe_int(metadata.get("origin_asn"))
if not prefix:
continue
if origin_asn is not None:
prefix_to_origins[prefix].add(origin_asn)
if metadata.get("is_more_specific"):
prefix_to_more_specifics[prefix.split("/")[0]].append(event)
if metadata.get("event_type") == "withdrawal":
withdrawal_counter[(prefix, origin_asn)] += 1
for prefix, origins in prefix_to_origins.items():
historic = previous_origin_map.get(prefix, set())
new_origins = sorted(origin for origin in origins if origin not in historic)
if historic and new_origins:
for new_origin in new_origins:
pending_anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="origin_change",
severity="critical",
status="active",
entity_key=f"origin_change:{prefix}:{new_origin}",
prefix=prefix,
origin_asn=sorted(historic)[0],
new_origin_asn=new_origin,
peer_scope=[],
started_at=datetime.now(UTC),
confidence=0.86,
summary=f"Prefix {prefix} is now originated by AS{new_origin}, outside the current baseline.",
evidence={"previous_origins": sorted(historic), "current_origins": sorted(origins)},
)
)
for root_prefix, more_specifics in prefix_to_more_specifics.items():
if len(more_specifics) >= 2:
sample = more_specifics[0]["metadata"]
pending_anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="more_specific_burst",
severity="high",
status="active",
entity_key=f"more_specific_burst:{root_prefix}:{len(more_specifics)}",
prefix=sample.get("prefix"),
origin_asn=_safe_int(sample.get("origin_asn")),
new_origin_asn=None,
peer_scope=sorted(
{
str(item.get("metadata", {}).get("collector") or "")
for item in more_specifics
if item.get("metadata", {}).get("collector")
}
),
started_at=datetime.now(UTC),
confidence=0.72,
summary=f"{len(more_specifics)} more-specific announcements clustered around {root_prefix}.",
evidence={"events": [item.get("metadata") for item in more_specifics[:10]]},
)
)
for (prefix, origin_asn), count in withdrawal_counter.items():
if count >= 3:
pending_anomalies.append(
BGPAnomaly(
snapshot_id=snapshot_id,
task_id=task_id,
source=source,
anomaly_type="mass_withdrawal",
severity="high" if count < 8 else "critical",
status="active",
entity_key=f"mass_withdrawal:{prefix}:{origin_asn}:{count}",
prefix=prefix,
origin_asn=origin_asn,
new_origin_asn=None,
peer_scope=[],
started_at=datetime.now(UTC),
confidence=min(0.55 + (count * 0.05), 0.95),
summary=f"{count} withdrawal events observed for {prefix} in the current ingest window.",
evidence={"withdrawal_count": count},
)
)
if not pending_anomalies:
return 0
existing_result = await db.execute(
select(BGPAnomaly.entity_key).where(
BGPAnomaly.entity_key.in_([item.entity_key for item in pending_anomalies])
)
)
existing_keys = {row[0] for row in existing_result.fetchall()}
created = 0
for anomaly in pending_anomalies:
if anomaly.entity_key in existing_keys:
continue
db.add(anomaly)
created += 1
if created:
await db.commit()
return created

View File

@@ -0,0 +1,120 @@
"""BGPStream backfill collector."""
from __future__ import annotations
import asyncio
import json
import time
import urllib.parse
import urllib.request
from typing import Any
from app.services.collectors.base import BaseCollector
from app.services.collectors.bgp_common import create_bgp_anomalies_for_batch, normalize_bgp_event
class BGPStreamBackfillCollector(BaseCollector):
name = "bgpstream_bgp"
priority = "P1"
module = "L3"
frequency_hours = 6
data_type = "bgp_rib"
fail_on_empty = True
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("BGPStream URL is not configured")
return await asyncio.to_thread(self._fetch_resource_windows)
def _fetch_resource_windows(self) -> list[dict[str, Any]]:
end = int(time.time()) - 3600
start = end - 86400
params = [
("projects[]", "routeviews"),
("collectors[]", "route-views2"),
("types[]", "updates"),
("intervals[]", f"{start},{end}"),
]
url = f"{self._resolved_url}/data?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(
url,
headers={"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)"},
)
with urllib.request.urlopen(request, timeout=30) as response:
body = json.loads(response.read().decode())
if body.get("error"):
raise RuntimeError(f"BGPStream broker error: {body['error']}")
return body.get("data", {}).get("resources", [])
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
transformed: list[dict[str, Any]] = []
for item in raw_data:
if not isinstance(item, dict):
continue
is_broker_window = any(key in item for key in ("filename", "url", "startTime", "start_time"))
if {"collector", "prefix"} <= set(item.keys()) and not is_broker_window:
transformed.append(normalize_bgp_event(item, project="bgpstream"))
continue
# Broker responses provide file windows rather than decoded events.
collector = item.get("collector") or item.get("project") or "bgpstream"
timestamp = item.get("time") or item.get("startTime") or item.get("start_time")
name = item.get("filename") or item.get("url") or f"{collector}-window"
normalized = normalize_bgp_event(
{
"collector": collector,
"event_type": "rib",
"prefix": item.get("prefix") or "historical-window",
"timestamp": timestamp,
"origin_asn": item.get("origin_asn"),
"path": item.get("path") or [],
"raw_message": item,
},
project="bgpstream",
)
transformed.append(
normalized
| {
"name": name,
"title": f"BGPStream {collector}",
"description": "Historical BGPStream backfill window",
"metadata": {
**normalized["metadata"],
"broker_record": item,
},
}
)
self._latest_transformed_batch = transformed
return transformed
async def run(self, db):
result = await super().run(db)
if result.get("status") != "success":
return result
snapshot_id = await self._resolve_snapshot_id(db, result.get("task_id"))
anomaly_count = await create_bgp_anomalies_for_batch(
db,
source=self.name,
snapshot_id=snapshot_id,
task_id=result.get("task_id"),
events=getattr(self, "_latest_transformed_batch", []),
)
result["anomalies_created"] = anomaly_count
return result
async def _resolve_snapshot_id(self, db, task_id: int | None) -> int | None:
if task_id is None:
return None
from sqlalchemy import select
from app.models.data_snapshot import DataSnapshot
result = await db.execute(
select(DataSnapshot.id).where(DataSnapshot.task_id == task_id).order_by(DataSnapshot.id.desc())
)
return result.scalar_one_or_none()

View File

@@ -8,6 +8,7 @@ import json
from typing import Dict, Any, List
import httpx
from app.core.satellite_tle import build_tle_lines_from_elements
from app.services.collectors.base import BaseCollector
@@ -61,6 +62,17 @@ class CelesTrakTLECollector(BaseCollector):
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
transformed = []
for item in raw_data:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=item.get("NORAD_CAT_ID"),
epoch=item.get("EPOCH"),
inclination=item.get("INCLINATION"),
raan=item.get("RA_OF_ASC_NODE"),
eccentricity=item.get("ECCENTRICITY"),
arg_of_perigee=item.get("ARG_OF_PERICENTER"),
mean_anomaly=item.get("MEAN_ANOMALY"),
mean_motion=item.get("MEAN_MOTION"),
)
transformed.append(
{
"name": item.get("OBJECT_NAME", "Unknown"),
@@ -80,6 +92,10 @@ class CelesTrakTLECollector(BaseCollector):
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
# Prefer the original TLE lines when the source provides them.
# If they are missing, store a normalized TLE pair built once on the backend.
"tle_line1": item.get("TLE_LINE1") or tle_line1,
"tle_line2": item.get("TLE_LINE2") or tle_line2,
},
}
)

View File

@@ -10,7 +10,7 @@ Some endpoints require authentication for higher rate limits.
import asyncio
import os
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
import httpx
from app.services.collectors.base import HTTPCollector
@@ -59,7 +59,7 @@ class CloudflareRadarDeviceCollector(HTTPCollector):
"other_percent": float(summary.get("other", 0)),
"date_range": result.get("meta", {}).get("dateRange", {}),
},
"reference_date": datetime.utcnow().isoformat(),
"reference_date": datetime.now(UTC).isoformat(),
}
data.append(entry)
except (ValueError, TypeError, KeyError):
@@ -107,7 +107,7 @@ class CloudflareRadarTrafficCollector(HTTPCollector):
"requests": item.get("requests"),
"visit_duration": item.get("visitDuration"),
},
"reference_date": item.get("datetime", datetime.utcnow().isoformat()),
"reference_date": item.get("datetime", datetime.now(UTC).isoformat()),
}
data.append(entry)
except (ValueError, TypeError, KeyError):
@@ -155,7 +155,7 @@ class CloudflareRadarTopASCollector(HTTPCollector):
"traffic_share": item.get("trafficShare"),
"country_code": item.get("location", {}).get("countryCode"),
},
"reference_date": datetime.utcnow().isoformat(),
"reference_date": datetime.now(UTC).isoformat(),
}
data.append(entry)
except (ValueError, TypeError, KeyError):

View File

@@ -6,7 +6,7 @@ https://epoch.ai/data/gpu-clusters
import re
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
from bs4 import BeautifulSoup
import httpx
@@ -64,7 +64,7 @@ class EpochAIGPUCollector(BaseCollector):
"metadata": {
"raw_data": perf_cell,
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
data.append(entry)
except (ValueError, IndexError, AttributeError):
@@ -114,6 +114,6 @@ class EpochAIGPUCollector(BaseCollector):
"metadata": {
"note": "Sample data - Epoch AI page structure may vary",
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
},
]

View File

@@ -4,7 +4,7 @@ Collects landing point data from FAO CSV API.
"""
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
import httpx
from app.services.collectors.base import BaseCollector
@@ -58,7 +58,7 @@ class FAOLandingPointCollector(BaseCollector):
"is_tbd": is_tbd,
"original_id": feature_id,
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, IndexError):

View File

@@ -7,7 +7,7 @@ https://huggingface.co/spaces
"""
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
from app.services.collectors.base import HTTPCollector
@@ -46,7 +46,7 @@ class HuggingFaceModelCollector(HTTPCollector):
"library_name": item.get("library_name"),
"created_at": item.get("createdAt"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
data.append(entry)
except (ValueError, TypeError, KeyError):
@@ -87,7 +87,7 @@ class HuggingFaceDatasetCollector(HTTPCollector):
"tags": (item.get("tags", []) or [])[:10],
"created_at": item.get("createdAt"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
data.append(entry)
except (ValueError, TypeError, KeyError):
@@ -128,7 +128,7 @@ class HuggingFaceSpacesCollector(HTTPCollector):
"tags": (item.get("tags", []) or [])[:10],
"created_at": item.get("createdAt"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
data.append(entry)
except (ValueError, TypeError, KeyError):

View File

@@ -13,7 +13,7 @@ To get higher limits, set PEERINGDB_API_KEY environment variable.
import asyncio
import os
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
import httpx
from app.services.collectors.base import HTTPCollector
@@ -106,7 +106,7 @@ class PeeringDBIXPCollector(HTTPCollector):
"created": item.get("created"),
"updated": item.get("updated"),
},
"reference_date": datetime.utcnow().isoformat(),
"reference_date": datetime.now(UTC).isoformat(),
}
data.append(entry)
except (ValueError, TypeError, KeyError):
@@ -209,7 +209,7 @@ class PeeringDBNetworkCollector(HTTPCollector):
"created": item.get("created"),
"updated": item.get("updated"),
},
"reference_date": datetime.utcnow().isoformat(),
"reference_date": datetime.now(UTC).isoformat(),
}
data.append(entry)
except (ValueError, TypeError, KeyError):
@@ -311,7 +311,7 @@ class PeeringDBFacilityCollector(HTTPCollector):
"created": item.get("created"),
"updated": item.get("updated"),
},
"reference_date": datetime.utcnow().isoformat(),
"reference_date": datetime.now(UTC).isoformat(),
}
data.append(entry)
except (ValueError, TypeError, KeyError):

View File

@@ -0,0 +1,131 @@
"""RIPE RIS Live collector."""
from __future__ import annotations
import asyncio
import json
import urllib.request
from typing import Any
from app.services.collectors.base import BaseCollector
from app.services.collectors.bgp_common import create_bgp_anomalies_for_batch, normalize_bgp_event
class RISLiveCollector(BaseCollector):
name = "ris_live_bgp"
priority = "P1"
module = "L3"
frequency_hours = 1
data_type = "bgp_update"
fail_on_empty = True
max_messages = 100
idle_timeout_seconds = 15
async def fetch(self) -> list[dict[str, Any]]:
if not self._resolved_url:
raise RuntimeError("RIS Live URL is not configured")
return await asyncio.to_thread(self._fetch_via_stream)
def _fetch_via_stream(self) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
stream_url = "https://ris-live.ripe.net/v1/stream/?format=json&client=planet-ris-live"
subscribe = json.dumps(
{
"host": "rrc00",
"type": "UPDATE",
"require": "announcements",
}
)
request = urllib.request.Request(
stream_url,
headers={"X-RIS-Subscribe": subscribe},
)
with urllib.request.urlopen(request, timeout=20) as response:
while len(events) < self.max_messages:
line = response.readline().decode().strip()
if not line:
break
payload = json.loads(line)
if payload.get("type") != "ris_message":
continue
data = payload.get("data", {})
if isinstance(data, dict):
events.append(data)
return events
def transform(self, raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
transformed: list[dict[str, Any]] = []
for item in raw_data:
announcements = item.get("announcements") or []
withdrawals = item.get("withdrawals") or []
for announcement in announcements:
next_hop = announcement.get("next_hop")
for prefix in announcement.get("prefixes") or []:
transformed.append(
normalize_bgp_event(
{
**item,
"collector": item.get("host", "").replace(".ripe.net", ""),
"event_type": "announcement",
"prefix": prefix,
"next_hop": next_hop,
},
project="ris-live",
)
)
for prefix in withdrawals:
transformed.append(
normalize_bgp_event(
{
**item,
"collector": item.get("host", "").replace(".ripe.net", ""),
"event_type": "withdrawal",
"prefix": prefix,
},
project="ris-live",
)
)
if not announcements and not withdrawals:
transformed.append(
normalize_bgp_event(
{
**item,
"collector": item.get("host", "").replace(".ripe.net", ""),
},
project="ris-live",
)
)
self._latest_transformed_batch = transformed
return transformed
async def run(self, db):
result = await super().run(db)
if result.get("status") != "success":
return result
snapshot_id = await self._resolve_snapshot_id(db, result.get("task_id"))
anomaly_count = await create_bgp_anomalies_for_batch(
db,
source=self.name,
snapshot_id=snapshot_id,
task_id=result.get("task_id"),
events=getattr(self, "_latest_transformed_batch", []),
)
result["anomalies_created"] = anomaly_count
return result
async def _resolve_snapshot_id(self, db, task_id: int | None) -> int | None:
if task_id is None:
return None
from sqlalchemy import select
from app.models.data_snapshot import DataSnapshot
result = await db.execute(
select(DataSnapshot.id).where(DataSnapshot.task_id == task_id).order_by(DataSnapshot.id.desc())
)
return result.scalar_one_or_none()

View File

@@ -10,6 +10,7 @@ import httpx
from app.services.collectors.base import BaseCollector
from app.core.data_sources import get_data_sources_config
from app.core.satellite_tle import build_tle_lines_from_elements
class SpaceTrackTLECollector(BaseCollector):
@@ -169,25 +170,41 @@ class SpaceTrackTLECollector(BaseCollector):
"""Transform TLE data to internal format"""
transformed = []
for item in raw_data:
tle_line1, tle_line2 = build_tle_lines_from_elements(
norad_cat_id=item.get("NORAD_CAT_ID"),
epoch=item.get("EPOCH"),
inclination=item.get("INCLINATION"),
raan=item.get("RAAN"),
eccentricity=item.get("ECCENTRICITY"),
arg_of_perigee=item.get("ARG_OF_PERIGEE"),
mean_anomaly=item.get("MEAN_ANOMALY"),
mean_motion=item.get("MEAN_MOTION"),
)
transformed.append(
{
"name": item.get("OBJECT_NAME", "Unknown"),
"norad_cat_id": item.get("NORAD_CAT_ID"),
"international_designator": item.get("INTL_DESIGNATOR"),
"epoch": item.get("EPOCH"),
"mean_motion": item.get("MEAN_MOTION"),
"eccentricity": item.get("ECCENTRICITY"),
"inclination": item.get("INCLINATION"),
"raan": item.get("RAAN"),
"arg_of_perigee": item.get("ARG_OF_PERIGEE"),
"mean_anomaly": item.get("MEAN_ANOMALY"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
"classification_type": item.get("CLASSIFICATION_TYPE"),
"element_set_no": item.get("ELEMENT_SET_NO"),
"rev_at_epoch": item.get("REV_AT_EPOCH"),
"bstar": item.get("BSTAR"),
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
"reference_date": item.get("EPOCH", ""),
"metadata": {
"norad_cat_id": item.get("NORAD_CAT_ID"),
"international_designator": item.get("INTL_DESIGNATOR"),
"epoch": item.get("EPOCH"),
"mean_motion": item.get("MEAN_MOTION"),
"eccentricity": item.get("ECCENTRICITY"),
"inclination": item.get("INCLINATION"),
"raan": item.get("RAAN"),
"arg_of_perigee": item.get("ARG_OF_PERIGEE"),
"mean_anomaly": item.get("MEAN_ANOMALY"),
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
"classification_type": item.get("CLASSIFICATION_TYPE"),
"element_set_no": item.get("ELEMENT_SET_NO"),
"rev_at_epoch": item.get("REV_AT_EPOCH"),
"bstar": item.get("BSTAR"),
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
# Prefer original lines from the source, but keep a backend-built pair as a stable fallback.
"tle_line1": item.get("TLE_LINE1") or item.get("TLE1") or tle_line1,
"tle_line2": item.get("TLE_LINE2") or item.get("TLE2") or tle_line2,
},
}
)
return transformed

View File

@@ -7,7 +7,7 @@ Uses Wayback Machine as backup data source since live data requires JavaScript r
import json
import re
from typing import Dict, Any, List
from datetime import datetime
from datetime import UTC, datetime
from bs4 import BeautifulSoup
import httpx
@@ -103,7 +103,7 @@ class TeleGeographyCableCollector(BaseCollector):
"capacity_tbps": item.get("capacity"),
"url": item.get("url"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, TypeError, KeyError):
@@ -131,7 +131,7 @@ class TeleGeographyCableCollector(BaseCollector):
"owner": "Meta, Orange, Vodafone, etc.",
"status": "active",
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
},
{
"source_id": "telegeo_sample_2",
@@ -147,7 +147,7 @@ class TeleGeographyCableCollector(BaseCollector):
"owner": "Alibaba, NEC",
"status": "planned",
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
},
]
@@ -187,7 +187,7 @@ class TeleGeographyLandingPointCollector(BaseCollector):
"cable_count": len(item.get("cables", [])),
"url": item.get("url"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, TypeError, KeyError):
@@ -211,7 +211,7 @@ class TeleGeographyLandingPointCollector(BaseCollector):
"value": "",
"unit": "",
"metadata": {"note": "Sample data"},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
},
]
@@ -258,7 +258,7 @@ class TeleGeographyCableSystemCollector(BaseCollector):
"investment": item.get("investment"),
"url": item.get("url"),
},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
}
result.append(entry)
except (ValueError, TypeError, KeyError):
@@ -282,6 +282,6 @@ class TeleGeographyCableSystemCollector(BaseCollector):
"value": "5000",
"unit": "km",
"metadata": {"note": "Sample data"},
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
},
]

View File

@@ -2,7 +2,7 @@
import asyncio
import logging
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from typing import Any, Dict, Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -10,6 +10,7 @@ from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from app.db.session import async_session_factory
from app.core.time import to_iso8601_utc
from app.models.datasource import DataSource
from app.models.task import CollectionTask
from app.services.collectors.registry import collector_registry
@@ -79,12 +80,12 @@ async def run_collector_task(collector_name: str):
collector._datasource_id = datasource.id
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
task_result = await collector.run(db)
datasource.last_run_at = datetime.utcnow()
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = task_result.get("status")
await _update_next_run_at(datasource, db)
logger.info("Collector %s completed: %s", collector_name, task_result)
except Exception as exc:
datasource.last_run_at = datetime.utcnow()
datasource.last_run_at = datetime.now(UTC)
datasource.last_status = "failed"
await db.commit()
logger.exception("Collector %s failed: %s", collector_name, exc)
@@ -92,7 +93,7 @@ async def run_collector_task(collector_name: str):
async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
"""Mark stale running tasks as failed after restarts or collector hangs."""
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
cutoff = datetime.now(UTC) - timedelta(hours=max_age_hours)
async with async_session_factory() as db:
result = await db.execute(
@@ -107,7 +108,7 @@ async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int:
for task in stale_tasks:
task.status = "failed"
task.phase = "failed"
task.completed_at = datetime.utcnow()
task.completed_at = datetime.now(UTC)
existing_error = (task.error_message or "").strip()
cleanup_error = "Marked failed automatically after stale running task cleanup"
task.error_message = f"{existing_error}\n{cleanup_error}".strip() if existing_error else cleanup_error
@@ -167,7 +168,7 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]:
{
"id": job.id,
"name": job.name,
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
"next_run_time": to_iso8601_utc(job.next_run_time),
"trigger": str(job.trigger),
}
)

74
backend/tests/test_bgp.py Normal file
View File

@@ -0,0 +1,74 @@
"""Tests for BGP observability helpers."""
from app.models.bgp_anomaly import BGPAnomaly
from app.services.collectors.bgp_common import normalize_bgp_event
from app.services.collectors.bgpstream import BGPStreamBackfillCollector
def test_normalize_bgp_event_from_live_payload():
event = normalize_bgp_event(
{
"collector": "rrc00",
"peer_asn": "3333",
"peer_ip": "2001:db8::1",
"type": "UPDATE",
"event_type": "announcement",
"prefix": "203.0.113.0/24",
"path": ["3333", "64500", "64496"],
"communities": ["3333:100"],
"timestamp": "2026-03-26T08:00:00Z",
},
project="ris-live",
)
assert event["name"] == "203.0.113.0/24"
assert event["metadata"]["collector"] == "rrc00"
assert event["metadata"]["peer_asn"] == 3333
assert event["metadata"]["origin_asn"] == 64496
assert event["metadata"]["as_path_length"] == 3
assert event["metadata"]["prefix_length"] == 24
assert event["metadata"]["is_more_specific"] is False
def test_bgpstream_transform_preserves_broker_record():
collector = BGPStreamBackfillCollector()
transformed = collector.transform(
[
{
"project": "routeviews",
"collector": "route-views.sg",
"filename": "rib.20260326.0800.gz",
"startTime": "2026-03-26T08:00:00Z",
"prefix": "198.51.100.0/24",
"origin_asn": 64512,
}
]
)
assert len(transformed) == 1
record = transformed[0]
assert record["name"] == "rib.20260326.0800.gz"
assert record["metadata"]["project"] == "bgpstream"
assert record["metadata"]["broker_record"]["filename"] == "rib.20260326.0800.gz"
def test_bgp_anomaly_to_dict():
anomaly = BGPAnomaly(
source="ris_live_bgp",
anomaly_type="origin_change",
severity="critical",
status="active",
entity_key="origin_change:203.0.113.0/24:64497",
prefix="203.0.113.0/24",
origin_asn=64496,
new_origin_asn=64497,
summary="Origin ASN changed",
confidence=0.9,
evidence={"previous_origins": [64496], "current_origins": [64497]},
)
data = anomaly.to_dict()
assert data["source"] == "ris_live_bgp"
assert data["anomaly_type"] == "origin_change"
assert data["new_origin_asn"] == 64497
assert data["evidence"]["previous_origins"] == [64496]

269
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,269 @@
# Changelog
All notable changes to `planet` are documented here.
This project follows the repository versioning rule:
- `feature` -> `+0.1.0`
- `bugfix` -> `+0.0.1`
## 0.21.8
Released: 2026-03-27
### Highlights
- Improved Earth-layer performance controls so satellite and cable toggles now fully unload their scene/runtime state instead of only hiding objects.
### Improved
- Improved the Earth satellite toggle to stop position/trail updates and release satellite rendering resources while disabled, then reload on demand when re-enabled.
- Improved the Earth cable toggle to unload submarine cable and landing-point objects while disabled so drag and interaction cost drops with the layer turned off.
- Improved Earth data reload behavior so disabled satellite and cable layers stay disabled instead of being implicitly reloaded during refresh.
## 0.21.7
Released: 2026-03-27
### Highlights
- Added Earth-side BGP collector visualization support so anomaly markers and collector stations can be explored together.
- Refined the collected-data distribution treemap so square tiles better reflect relative volume while staying readable in dense layouts.
### Added
- Added `/api/v1/visualization/geo/bgp-collectors` to expose RIPE RIS collector locations as GeoJSON.
- Added dedicated Earth collector marker handling and BGP collector detail cards in the Earth runtime.
- Added collector-specific BGP visual tuning for altitude, opacity, scale, and pulse behavior.
### Improved
- Improved the collected-data distribution treemap with dynamic square-grid sizing, clearer area-based span rules, centered compact tiles, and tooltip coverage on both icons and labels.
- Improved compact treemap readability by hiding `1x1` labels, reducing `1x1` value font size, and centering icon/value content.
- Improved Earth BGP interactions so anomaly markers and collector markers can both participate in hover, lock, legend, and info-card flows.
### Fixed
- Fixed Earth BGP data loading gaps by adding the missing `bgp.js` runtime module required by the current control and visualization flow.
- Fixed treemap layout drift where compact tiles could appear oversized or visually inconsistent with the intended square-grid distribution.
## 0.21.6
Released: 2026-03-27
### Highlights
- Refined the Earth page interaction loop with object-driven legend switching, clearer selection feedback, and cleaner HUD copy/layout behavior.
- Improved the Earth info surfaces so status toasts, info-card interactions, and title/subtitle presentation feel more intentional and easier to scan.
### Added
- Added click-to-copy support for info-card labels so clicking a field label copies the matching field value.
- Added runtime-generated legend content for cables and satellites based on current Earth data and selection state.
### Improved
- Improved Earth legend behavior so selected cables and selected satellite categories are promoted to the top of the legend list.
- Improved legend overflow handling by constraining the visible list and using scroll for additional entries.
- Improved info-panel heading layout with centered title/subtitle styling and better subtitle hierarchy.
- Improved status-message behavior with replayable slide-in notifications when messages change in quick succession.
### Fixed
- Fixed info-card content spacing by targeting the actual `#info-card-content` node instead of a non-matching class selector.
- Fixed cable legend generation so it follows backend-returned cable names and colors instead of stale hard-coded placeholder categories.
- Fixed reset-view and legend-related HUD behaviors so selection and legend state stay in sync when users interact with real Earth objects.
## 0.21.5
Released: 2026-03-27
### Highlights
- Reworked the collected-data overview into a clearer split between KPI cards and a switchable treemap distribution.
- Added a direct Earth entry on the dashboard and tightened several admin-side scrolling/layout behaviors.
### Added
- Added a dashboard quick-access card linking directly to `/earth`.
- Added collected-data treemap switching between `按数据源` and `按类型`.
- Added data-type-specific icons for the collected-data overview treemap.
### Improved
- Improved collected-data summary behavior so overview counts follow the active filters and search state.
- Improved the collected-data treemap with square tiles, a wider default overview panel width, and narrower overview scrollbars.
- Improved responsive behavior near the tablet breakpoint so the collected-data page can scroll instead of clipping the overview or crushing the table.
### Fixed
- Fixed the user-management table overflow issue by restoring `ant-table-body` to auto height for that page so the outer container no longer incorrectly takes over vertical scrolling.
- Fixed overly wide scrollbar presentation in collected-data and related admin surfaces by aligning them with the slimmer in-app scrollbar style.
## 0.21.3
Released: 2026-03-27
### Highlights
- Upgraded the startup script into a more resilient local control entrypoint with retry-based service boot, selective restart targeting, and guided CLI user creation.
- Reduced friction when developing across slower machines by making backend and frontend startup checks more tolerant and operator-friendly.
### Added
- Added interactive `createuser` support to [planet.sh](/home/ray/dev/linkong/planet/planet.sh) for CLI-driven username, email, password, and admin-role creation.
### Improved
- Improved `start` and `restart` in [planet.sh](/home/ray/dev/linkong/planet/planet.sh) with optional backend/frontend port targeting and on-demand port cleanup.
- Improved startup robustness with repeated health checks and automatic retry loops for both backend and frontend services.
- Improved restart ergonomics so `restart -b` and `restart -f` can restart only the requested service instead of forcing a full stack restart.
### Fixed
- Fixed false startup failures on slower environments where services needed longer than a single fixed wait window to become healthy.
- Fixed first-run login dead-end by ensuring a default admin user is created during backend initialization when the database has no users.
## 0.21.2
Released: 2026-03-26
### Highlights
- Reworked the Earth page HUD into a bottom-centered floating toolbar with grouped popovers and richer interaction feedback.
- Unified toolbar and corner cards under a liquid-glass visual language and refined status toasts, object info cards, and legend behavior.
- Made the legend state reflect the currently selected Earth object instead of a fixed static list.
### Added
- Added a reusable Earth legend module in [legend.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/legend.js).
- Added Material Symbols-based Earth toolbar icons and dedicated fullscreen-collapse icon support.
- Added click-to-copy support for info-card field labels.
### Improved
- Improved Earth toolbar layout with centered floating controls, popover-based display toggles, and zoom controls.
- Improved Earth HUD visuals with liquid-glass styling for buttons, info cards, panels, and animated status messages.
- Improved info-card spacing, scrollbar styling, and object detail readability.
- Improved legend rendering so cable and satellite object selection can drive the displayed legend content.
### Fixed
- Fixed tooltip coverage and splash copy mismatches in the Earth page controls.
- Fixed several toolbar icon clarity, centering, and state-toggle issues.
- Fixed status-message behavior so repeated notifications replay the slide-in animation.
## 0.20.0
Released: 2026-03-26
### Highlights
- Stabilized the Earth big-screen module for longer-running sessions.
- Fixed satellite orbit generation by correcting TLE handling end to end.
- Added a reusable backend TLE helper and exposed `tle_line1 / tle_line2` to the Earth frontend.
### Added
- Added a dedicated Earth module remediation plan in [earth-module-plan.md](/home/ray/dev/linkong/planet/docs/earth-module-plan.md).
- Added backend TLE helpers in [satellite_tle.py](/home/ray/dev/linkong/planet/backend/app/core/satellite_tle.py).
- Added backend support for returning `tle_line1` and `tle_line2` from the satellite visualization API.
### Improved
- Reworked the Earth module lifecycle with cleaner init, reload, and destroy paths.
- Improved scene cleanup for cables, landing points, satellite markers, and related runtime state.
- Reduced Earth interaction overhead by reusing hot-path math and pointer objects.
- Switched satellite animation timing to real delta-based updates for more stable motion.
- Reduced fragile global-state coupling inside the legacy Earth runtime.
### Fixed
- Fixed white-screen risk caused by iframe cleanup behavior in development mode.
- Fixed incorrect client-side TLE generation:
- corrected line 2 field ordering
- corrected eccentricity formatting
- added checksum generation
- Fixed fallback orbit issues affecting some Starlink satellites such as `STARLINK-36158`.
- Fixed partial Earth data load failures so one failed source is less likely to break the whole view.
### Notes
- The Earth frontend now prefers backend-provided raw TLE lines.
- Older satellite records can still fall back to backend-generated TLE lines when raw lines are unavailable.
- This release is primarily focused on Earth module stability rather than visible admin UI changes.
## 0.21.1
Released: 2026-03-26
### Highlights
- Refined the Earth big-screen toolbar with clearer controls, hover hints, and more consistent visual language.
- Replaced emoji-based Earth toolbar controls with SVG icons for a cleaner HUD.
- Updated the Earth loading splash so manual reloads no longer show legacy wording.
### Improved
- Improved zoom controls by adding tooltips for reset view, zoom in, zoom out, and resetting zoom to `100%`.
- Improved Earth toolbar readability with larger icons and revised glyphs for rotation, reload, satellites, trails, cables, terrain, and collapse.
- Improved loading overlay copy to better distinguish initial initialization from manual refresh.
### Fixed
- Fixed rotate toggle rendering so play/pause state no longer relies on emoji text replacement.
- Fixed Earth autorotation target syncing so inertial drag is preserved while the globe is still coasting.
## 0.21.0
Released: 2026-03-26
### Highlights
- Added legacy-inspired inertial drag behavior to the Earth big-screen module.
- Removed the hard 10,000-satellite ceiling when Earth satellite loading is configured as unlimited.
- Tightened Earth toolbar and hover-state synchronization for a more consistent runtime feel.
### Added
- Added inertial drag state and smoothing to the Earth runtime so drag release now decays naturally.
### Improved
- Improved drag handling so moving the pointer outside the canvas no longer prematurely stops rotation.
- Improved satellite loading to support dynamic frontend buffer sizing when no explicit limit is set.
- Improved Earth interaction fidelity by keeping the hover ring synchronized with moving satellites.
### Fixed
- Fixed the trails toolbar button so its default visual state matches the actual default runtime state.
- Fixed the satellite GeoJSON endpoint so omitting `limit` no longer silently falls back to `10000`.
- Fixed hover ring lag where the ring could stay behind the satellite until the next mouse move.
## 0.19.0
Released: 2026-03-25
### Highlights
- Refined data collection storage and history handling.
- Moved collected data away from several strongly coupled legacy columns.
- Improved data list filtering, metadata-driven detail rendering, and collection workflows.
### Added
- Added collected data history planning docs.
- Added metadata backfill and removal-readiness scripts.
- Added version history tracking.
### Improved
- Improved datasource task tracking and collection status flow.
- Improved collected data search, filtering, and metadata rendering.
- Improved configuration center layout consistency across admin pages.
### Fixed
- Fixed several collected-data field mapping issues.
- Fixed frontend table layout inconsistencies across multiple admin pages.
- Fixed TOP500 parsing and related metadata alignment issues.

View File

@@ -0,0 +1,487 @@
# BGP Observability Plan
## Goal
Build a global routing observability capability on top of:
- [RIPE RIS Live](https://ris-live.ripe.net/)
- [CAIDA BGPStream data access overview](https://bgpstream.caida.org/docs/overview/data-access)
The target is to support:
- real-time routing event ingestion
- historical replay and baseline analysis
- anomaly detection
- Earth big-screen visualization
## Important Scope Note
These data sources expose the BGP control plane, not user traffic itself.
That means the system can infer:
- route propagation direction
- prefix reachability changes
- AS path changes
- visibility changes across collectors
But it cannot directly measure:
- exact application traffic volume
- exact user packet path
- real bandwidth consumption between countries or operators
Product wording should therefore use phrases like:
- global routing propagation
- route visibility
- control-plane anomalies
- suspected path diversion
Instead of claiming direct traffic measurement.
## Data Source Roles
### RIS Live
Use RIS Live as the real-time feed.
Recommended usage:
- subscribe to update streams over WebSocket
- ingest announcements and withdrawals continuously
- trigger low-latency alerts
Best suited for:
- hijack suspicion
- withdrawal bursts
- real-time path changes
- live Earth event overlay
### BGPStream
Use BGPStream as the historical and replay layer.
Recommended usage:
- backfill time windows
- build normal baselines
- compare current events against history
- support investigations and playback
Best suited for:
- historical anomaly confirmation
- baseline path frequency
- visibility baselines
- postmortem analysis
## Recommended Architecture
```mermaid
flowchart LR
A["RIS Live WebSocket"] --> B["Realtime Collector"]
C["BGPStream Historical Access"] --> D["Backfill Collector"]
B --> E["Normalization Layer"]
D --> E
E --> F["data_snapshots"]
E --> G["collected_data"]
E --> H["bgp_anomalies"]
H --> I["Alerts API"]
G --> J["Visualization API"]
H --> J
J --> K["Earth Big Screen"]
```
## Storage Design
The current project already has:
- [data_snapshot.py](/home/ray/dev/linkong/planet/backend/app/models/data_snapshot.py)
- [collected_data.py](/home/ray/dev/linkong/planet/backend/app/models/collected_data.py)
So the lowest-risk path is:
1. keep raw and normalized BGP events in `collected_data`
2. use `data_snapshots` to group each ingest window
3. add a dedicated anomaly table for higher-value derived events
## Proposed Data Types
### `collected_data`
Use these `source` values:
- `ris_live_bgp`
- `bgpstream_bgp`
Use these `data_type` values:
- `bgp_update`
- `bgp_rib`
- `bgp_visibility`
- `bgp_path_change`
Recommended stable fields:
- `source`
- `source_id`
- `entity_key`
- `data_type`
- `name`
- `reference_date`
- `metadata`
Recommended `entity_key` strategy:
- event entity: `collector|peer|prefix|event_time`
- prefix state entity: `collector|peer|prefix`
- origin state entity: `prefix|origin_asn`
### `metadata` schema for raw events
Store the normalized event payload in `metadata`:
```json
{
"project": "ris-live",
"collector": "rrc00",
"peer_asn": 3333,
"peer_ip": "2001:db8::1",
"event_type": "announcement",
"prefix": "203.0.113.0/24",
"origin_asn": 64496,
"as_path": [3333, 64500, 64496],
"communities": ["3333:100", "64500:1"],
"next_hop": "192.0.2.1",
"med": 0,
"local_pref": null,
"timestamp": "2026-03-26T08:00:00Z",
"raw_message": {}
}
```
### New anomaly table
Add a new table, recommended name: `bgp_anomalies`
Suggested columns:
- `id`
- `snapshot_id`
- `task_id`
- `source`
- `anomaly_type`
- `severity`
- `status`
- `entity_key`
- `prefix`
- `origin_asn`
- `new_origin_asn`
- `peer_scope`
- `started_at`
- `ended_at`
- `confidence`
- `summary`
- `evidence`
- `created_at`
This table should represent derived intelligence, not raw updates.
## Collector Design
## 1. `RISLiveCollector`
Responsibility:
- maintain WebSocket connection
- subscribe to relevant message types
- normalize messages
- write event batches into snapshots
- optionally emit derived anomalies in near real time
Suggested runtime mode:
- long-running background task
Suggested snapshot strategy:
- one snapshot per rolling time window
- for example every 1 minute or every 5 minutes
## 2. `BGPStreamBackfillCollector`
Responsibility:
- fetch historical data windows
- normalize to the same schema as real-time data
- build baselines
- re-run anomaly rules on past windows if needed
Suggested runtime mode:
- scheduled task
- or ad hoc task for investigations
Suggested snapshot strategy:
- one snapshot per historical query window
## Normalization Rules
Normalize both sources into the same internal event model.
Required normalized fields:
- `collector`
- `peer_asn`
- `peer_ip`
- `event_type`
- `prefix`
- `origin_asn`
- `as_path`
- `timestamp`
Derived normalized fields:
- `as_path_length`
- `country_guess`
- `prefix_length`
- `is_more_specific`
- `visibility_weight`
## Anomaly Detection Rules
Start with these five rules first.
### 1. Origin ASN Change
Trigger when:
- the same prefix is announced by a new origin ASN not seen in the baseline window
Use for:
- hijack suspicion
- origin drift detection
### 2. More-Specific Burst
Trigger when:
- a more-specific prefix appears suddenly
- especially from an unexpected origin ASN
Use for:
- subprefix hijack suspicion
### 3. Mass Withdrawal
Trigger when:
- the same prefix or ASN sees many withdrawals across collectors within a short window
Use for:
- outage suspicion
- regional incident detection
### 4. Path Deviation
Trigger when:
- AS path length jumps sharply
- or a rarely seen transit ASN appears
- or path frequency drops below baseline norms
Use for:
- route leak suspicion
- unusual path diversion
### 5. Visibility Drop
Trigger when:
- a prefix is visible from far fewer collectors/peers than its baseline
Use for:
- regional reachability degradation
## Baseline Strategy
Use BGPStream historical data to build:
- common origin ASN per prefix
- common AS path patterns
- collector visibility distribution
- normal withdrawal frequency
Recommended baseline windows:
- short baseline: last 24 hours
- medium baseline: last 7 days
- long baseline: last 30 days
The first implementation can start with only the 7-day baseline.
## API Design
### Raw event API
Add endpoints like:
- `GET /api/v1/bgp/events`
- `GET /api/v1/bgp/events/{id}`
Suggested filters:
- `prefix`
- `origin_asn`
- `peer_asn`
- `collector`
- `event_type`
- `time_from`
- `time_to`
- `source`
### Anomaly API
Add endpoints like:
- `GET /api/v1/bgp/anomalies`
- `GET /api/v1/bgp/anomalies/{id}`
- `GET /api/v1/bgp/anomalies/summary`
Suggested filters:
- `severity`
- `anomaly_type`
- `status`
- `prefix`
- `origin_asn`
- `time_from`
- `time_to`
### Visualization API
Add an Earth-oriented endpoint like:
- `GET /api/v1/visualization/geo/bgp-anomalies`
Recommended feature shapes:
- point: collector locations
- arc: inferred propagation or suspicious path edge
- pulse point: active anomaly hotspot
## Earth Big-Screen Design
Recommended layers:
### Layer 1: Collector layer
Show known collector locations and current activity intensity.
### Layer 2: Route propagation arcs
Use arcs for:
- origin ASN country to collector country
- or collector-to-collector visibility edges
Important note:
This is an inferred propagation view, not real packet flow.
### Layer 3: Active anomaly overlay
Show:
- hijack suspicion in red
- mass withdrawal in orange
- visibility drop in yellow
- path deviation in blue
### Layer 4: Time playback
Use `data_snapshots` to replay:
- minute-by-minute route changes
- anomaly expansion
- recovery timeline
## Alerting Strategy
Map anomaly severity to the current alert system.
Recommended severity mapping:
- `critical`
- likely hijack
- very large withdrawal burst
- `high`
- clear origin change
- large visibility drop
- `medium`
- unusual path change
- moderate more-specific burst
- `low`
- weak or localized anomalies
## Delivery Plan
### Phase 1
- add `RISLiveCollector`
- normalize updates into `collected_data`
- create `bgp_anomalies`
- implement 3 rules:
- origin change
- more-specific burst
- mass withdrawal
### Phase 2
- add `BGPStreamBackfillCollector`
- build 7-day baseline
- implement:
- path deviation
- visibility drop
### Phase 3
- add Earth visualization layer
- add time playback
- add anomaly filtering and drilldown
## Practical Implementation Notes
- Start with IPv4 first, then add IPv6 after the event schema is stable.
- Store the original raw payload in `metadata.raw_message` for traceability.
- Deduplicate events by a stable hash of collector, peer, prefix, type, and timestamp.
- Keep anomaly generation idempotent so replay and backfill do not create duplicate alerts.
- Expect noisy data and partial views; confidence scoring matters.
## Recommended First Patch Set
The first code milestone should include:
1. `backend/app/services/collectors/ris_live.py`
2. `backend/app/services/collectors/bgpstream.py`
3. `backend/app/models/bgp_anomaly.py`
4. `backend/app/api/v1/bgp.py`
5. `backend/app/api/v1/visualization.py`
add BGP anomaly geo endpoint
6. `frontend/src/pages`
add a BGP anomaly list or summary page
7. `frontend/public/earth/js`
add BGP anomaly rendering layer
## Sources
- [RIPE RIS Live](https://ris-live.ripe.net/)
- [CAIDA BGPStream Data Access Overview](https://bgpstream.caida.org/docs/overview/data-access)

210
docs/earth-module-plan.md Normal file
View File

@@ -0,0 +1,210 @@
# Earth 模块整治计划
## 背景
`planet` 前端中的 Earth 模块是当前最重要的大屏 3D 星球展示能力,但它仍以 legacy iframe 页面形式存在:
- React 页面入口仅为 [Earth.tsx](/home/ray/dev/linkong/planet/frontend/src/pages/Earth/Earth.tsx)
- 实际 3D 实现位于 [frontend/public/earth](/home/ray/dev/linkong/planet/frontend/public/earth)
当前模块已经具备基础展示能力,但在生命周期、性能、可恢复性、可维护性方面存在明显隐患,不适合长期无人值守的大屏场景直接扩展。
## 目标
本计划的目标不是立刻重写 Earth而是分阶段把它从“能跑的 legacy 展示页”提升到“可稳定运行、可持续演进的大屏核心模块”。
核心目标:
1. 先止血,解决资源泄漏、重载污染、假性卡顿等稳定性问题
2. 再梳理数据加载、交互和渲染循环,降低性能风险
3. 最后逐步从 iframe legacy 向可控模块化架构迁移
## 现阶段主要问题
### 1. 生命周期缺失
- 没有统一 `destroy()` / 卸载清理逻辑
- `requestAnimationFrame`
- `window/document/dom listeners`
- `THREE` geometry / material / texture
- 运行时全局状态
都没有系统回收
### 2. 数据重载不完整
- `reloadData()` 没有彻底清理旧场景对象
- cable、landing point、satellite 相关缓存与对象存在累积风险
### 3. 渲染与命中检测成本高
- 鼠标移动时频繁创建 `Raycaster` / `Vector2`
- cable 命中前会重复做 bounding box 计算
- 卫星每帧计算量偏高
### 4. 状态管理分裂
- 大量依赖 `window.*` 全局桥接
- 模块之间靠隐式共享状态通信
- React 外层无法有效感知 Earth 内部状态
### 5. 错误恢复弱
- 数据加载失败主要依赖 `console` 和轻提示
- 缺少统一重试、降级、局部失败隔离机制
## 分阶段计划
## Phase 1稳定性止血
目标:
- 不改视觉主形态
- 优先解决泄漏、卡死、重载污染
### 任务
1. 补 Earth 生命周期管理
- 为 [main.js](/home/ray/dev/linkong/planet/frontend/public/earth/js/main.js) 增加:
- `init()`
- `destroy()`
- `reloadData()`
三类明确入口
- 统一记录并释放:
- animation frame id
- interval / timeout
- DOM 事件监听
- `window` 暴露对象
2. 增加场景对象清理层
- 为 cable / landing point / satellite sprite / orbit line 提供统一清理函数
- reload 前先 dispose 旧对象,再重新加载
3. 增加 stale 状态恢复
- 页面重新进入时先清理上一次遗留选择态、hover 态、锁定态
- 避免 iframe reload 后出现旧状态残留
4. 加强失败提示
- 电缆、登陆点、卫星加载拆分为独立状态
- 某一类数据失败时,其它类型仍可继续显示
- 提供明确的页面内提示而不是只打 console
### 验收标准
- 页面重复进入 / 离开后内存不持续上涨
- 连续多次点“重新加载数据”后对象数量不异常增加
- 单一数据源加载失败时页面不整体失效
## Phase 2性能优化
目标:
- 控制鼠标交互和动画循环成本
- 提升大屏长时间运行的稳定帧率
### 任务
1. 复用交互对象
- 复用 `Raycaster``Vector2`、中间 `Vector3`
- 避免 `mousemove` 热路径中频繁 new 对象
2. 优化 cable 命中逻辑
- 提前缓存 cable 中心点 / bounding 数据
- 移除 `mousemove` 内重复 `computeBoundingBox()`
- 必要时增加分层命中:
- 先粗筛
- 再精确相交
3. 改造动画循环
- 使用真实 `deltaTime`
- 把卫星位置更新、呼吸动画、视觉状态更新拆成独立阶段
- 为不可见对象减少无意义更新
4. 卫星轨迹与预测轨道优化
- 评估轨迹更新频率
- 对高开销几何计算增加缓存
- 限制预测轨道生成频次
### 验收标准
- 鼠标移动时不明显掉帧
- 中高数据量下动画速度不受帧率明显影响
- 长时间运行 CPU/GPU 占用更平稳
## Phase 3架构收编
目标:
- 降低 legacy iframe 架构带来的维护成本
- 让 React 主应用重新获得对 Earth 模块的控制力
### 任务
1. 抽离 Earth App Shell
- 将数据加载、错误状态、控制面板状态抽到更明确的模块边界
- 减少 `window.*` 全局依赖
2. 规范模块通信
- 统一 `main / controls / cables / satellites / ui` 的状态流
- 明确只读配置、运行时状态、渲染对象的职责分层
3. 评估去 iframe 迁移
- 中期可以保留 public/legacy 资源目录
- 但逐步把 Earth 作为前端内嵌模块而不是完全孤立页面
### 验收标准
- Earth 内部状态不再大量依赖全局变量
- React 外层可以感知 Earth 加载状态和错误状态
- 后续功能开发不再必须修改多个 legacy 文件才能完成
## 优先级建议
### P0
- 生命周期清理
- reload 清理
- stale 状态恢复
### P1
- 命中检测优化
- 动画 `deltaTime`
- 数据加载失败隔离
### P2
- 全局状态收编
- iframe 架构迁移
## 推荐实施顺序
1. 先做 Phase 1
2. 再做交互热路径与动画循环优化
3. 最后再考虑架构迁移
## 风险提示
1. Earth 是 legacy 模块,修复时容易牵一发而动全身
2. 如果不先补清理逻辑,后续所有性能优化收益都会被泄漏问题吃掉
3. 如果过早重写而不先止血,短期会影响现有演示稳定性
## 当前建议
最值得马上启动的是一个小范围稳定性 sprint
- 生命周期清理
- reload 全量清理
- 错误状态隔离
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。

84
docs/version-history.md Normal file
View File

@@ -0,0 +1,84 @@
# Version History
## Rules
- 初始版本从 `0.0.1-beta` 开始
- 每次 `bugfix` 递增 `0.0.1`
- 每次 `feature` 递增 `0.1.0`
- `refactor / docs / maintenance` 默认不单独 bump 版本
## Assumptions
- 本文基于 `main``dev` 的非 merge commit 历史整理
- 对于既包含修复又明显引入新能力的提交,按 `feature` 处理
- `main` 表示已进入主线,`dev` 表示当前仍在开发分支上的增量
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.21.6`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.0.1-beta` | bootstrap | `main` | `e7033775` | first commit |
| `0.1.0` | feature | `main` | `6cb4398f` | Modularize 3D Earth page with ES Modules |
| `0.2.0` | feature | `main` | `aaae6a53` | Add cable graph service and data collectors |
| `0.2.1` | bugfix | `main` | `ceb1b728` | highlight all cable segments by cable_id |
| `0.3.0` | feature | `main` | `14d11cd9` | add ArcGIS landing points and cable-landing relation collectors |
| `0.4.0` | feature | `main` | `99771a88` | make ArcGIS data source URLs configurable |
| `0.5.0` | feature | `main` | `de325521` | add data sources config system and Earth API integration |
| `0.5.1` | bugfix | `main` | `b06cb460` | remove ignored files from tracking |
| `0.5.2` | bugfix | `main` | `948af2c8` | fix coordinates-display position |
| `0.6.0` | feature | `main` | `4e487b31` | upload new geo json |
| `0.6.1` | bugfix | `main` | `02991730` | add cable_id to API response for cable highlighting |
| `0.6.2` | bugfix | `main` | `c82e1d5a` | 修复 3D 地球坐标映射多个严重 bug |
| `0.7.0` | feature | `main` | `3b0e9dec` | 统一卫星和线缆锁定逻辑,使用 lockedObject 系统 |
| `0.7.1` | bugfix | `main` | `11a9dda9` | 修复 resetView 调用并统一启动脚本到 `planet.sh` |
| `0.7.2` | bugfix | `main` | `e21b783b` | 修复 ArcGIS landing GeoJSON 坐标解析错误 |
| `0.8.0` | feature | `main` | `f5083071` | 自动旋转按钮改为播放/暂停图标状态 |
| `0.8.1` | bugfix | `main` | `777891f8` | 修复 resetView 视角和离开地球隐藏 tooltip |
| `0.9.0` | feature | `main` | `1189fec0` | init view to China coordinates |
| `0.10.0` | feature | `main` | `6fabbcfe` | request geolocation on resetView, fallback to China |
| `0.11.0` | feature | `main` | `0ecc1bc5` | cable state management, hover/lock visual separation |
| `0.12.0` | feature | `main` | `bb6b18fe` | satellite dot rendering with hover/lock rings |
| `0.13.0` | feature | `main` | `3fcbae55` | add cable-landing point relation via `city_id` |
| `0.14.0` | feature | `main` | `96222b9e` | toolbar layout and cable breathing effect improvements |
| `0.15.0` | feature | `main` | `49a9c338` | toolbar and zoom improvements |
| `0.16.0` | feature | `main` | `78bb639a` | toolbar zoom improvements and toggle-cables |
| `0.16.1` | bugfix | `main` | `d9a64f77` | fix iframe scrollbar issue |
| `0.16.2` | bugfix | `main` | `af29e90c` | prevent cable hover/click when cables are hidden |
| `0.16.3` | bugfix | `main` | `eabdbdc8` | clear lock state when hiding satellites or cables |
| `0.16.4` | bugfix | `main` | `0c950262` | fix satellite trail origin line and sync button state |
| `0.16.5` | bugfix | `main` | `9d135bf2` | revert unstable toolbar change |
| `0.16.6` | bugfix | `dev` | `465129ee` | use timestamp-based trail filtering to prevent flash |
| `0.17.0` | feature | `dev` | `1784c057` | add predicted orbit display for locked satellites |
| `0.17.1` | bugfix | `dev` | `543fe35f` | fix ring size attenuation and breathing animation |
| `0.17.2` | bugfix | `dev` | `b9fbacad` | prevent selecting satellites on far side of earth |
| `0.17.3` | bugfix | `dev` | `b57d69c9` | remove debug console.log for ring create/update |
| `0.17.4` | bugfix | `dev` | `81a0ca5e` | fix back-facing detection with proper coordinate transform |
| `0.18.0` | feature | `dev` | `ef0fefdf` | persist system settings and refine admin layouts |
| `0.18.1` | bugfix | `dev` | `cc5f16f8` | fix settings layout and frontend startup checks |
| `0.19.0` | feature | `dev` | `020c1d50` | refine data management and collection workflows |
| `0.20.0` | feature | `dev` | `ce5feba3` | stabilize Earth module and fix satellite TLE handling |
| `0.21.0` | feature | `dev` | `pending` | add Earth inertial drag, sync hover/trail state, and support unlimited satellite loading |
| `0.21.1` | bugfix | `dev` | `pending` | polish Earth toolbar controls, icons, and loading copy |
| `0.21.2` | bugfix | `dev` | `pending` | redesign Earth HUD with liquid-glass controls, dynamic legend switching, and info-card interaction polish |
| `0.21.3` | bugfix | `dev` | `30a29a6e` | harden `planet.sh` startup controls, add selective restart and interactive user creation |
| `0.21.4` | bugfix | `dev` | `7ec9586f` | add Earth HUD backup snapshots and icon assets |
| `0.21.5` | bugfix | `dev` | `a761dfc5` | refine Earth legend item presentation |
| `0.21.6` | bugfix | `dev` | `pending` | improve Earth legend generation, info-card interactions, and HUD messaging polish |
## Maintenance Commits Not Counted as Version Bumps
这些提交被视为维护性工作,因此未单独递增版本号:
- `3145ff08` Add `.gitignore` and clean
- `4ada75ca` new branch
- `c2eba54d` 整理资源文件,添加 legacy 路由
- `82f7aa29` 提取地球坐标常量到 `EARTH_CONFIG`
- `d18e400f` remove dead code
- `869d661a` abstract cable highlight logic
- `4f922f13` extract satellite config to `SATELLITE_CONFIG`
- `3e3090d7` docs: add architecture refactor and webgl instancing plans

View File

@@ -1,12 +1,12 @@
{
"name": "planet-frontend",
"version": "1.0.0",
"version": "0.21.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planet-frontend",
"version": "1.0.0",
"version": "0.21.7",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"antd": "^5.12.5",

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "1.0.0",
"version": "0.21.8",
"private": true,
"dependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -0,0 +1,435 @@
/* base.css - 公共基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #0a0a1a;
color: #fff;
overflow: hidden;
}
#container {
position: relative;
width: 100vw;
height: 100vh;
}
#container.dragging {
cursor: grabbing;
}
/* Bottom Dock */
#right-toolbar-group {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 200;
}
#right-toolbar-group,
#info-panel,
#coordinates-display,
#legend,
#earth-stats {
transition:
top 0.45s ease,
right 0.45s ease,
bottom 0.45s ease,
left 0.45s ease,
transform 0.45s ease,
box-shadow 0.45s ease;
}
/* Zoom Toolbar - Right side, vertical */
#zoom-toolbar {
position: relative;
bottom: auto;
right: auto;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
#zoom-toolbar .zoom-percent {
font-size: 0.75rem;
font-weight: 600;
color: #4db8ff;
min-width: 30px;
display: inline-block;
text-align: center;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
#zoom-toolbar .zoom-percent:hover {
background: rgba(77, 184, 255, 0.2);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
}
#zoom-toolbar .zoom-btn {
width: 28px;
height: 28px;
min-width: 28px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.2);
color: #4db8ff;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
margin: 0;
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
}
#zoom-toolbar .zoom-btn:hover {
background: rgba(77, 184, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
}
#zoom-toolbar #reset-view svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 1.8;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
#zoom-toolbar .zoom-percent {
position: relative;
}
#zoom-toolbar .tooltip {
position: absolute;
bottom: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
pointer-events: none;
z-index: 100;
}
#zoom-toolbar .zoom-btn:hover .tooltip,
#zoom-toolbar .zoom-percent:hover .tooltip {
opacity: 1;
visibility: visible;
}
#zoom-toolbar .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.2rem;
color: #4db8ff;
z-index: 100;
text-align: center;
background-color: rgba(10, 10, 30, 0.95);
padding: 30px;
border-radius: 10px;
border: 1px solid #4db8ff;
box-shadow: 0 0 30px rgba(77,184,255,0.3);
}
#loading-spinner {
border: 4px solid rgba(77, 184, 255, 0.3);
border-top: 4px solid #4db8ff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
color: #ff4444;
margin-top: 10px;
font-size: 0.9rem;
display: none;
padding: 10px;
background-color: rgba(255, 68, 68, 0.1);
border-radius: 5px;
border-left: 3px solid #ff4444;
}
.terrain-controls {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.slider-container {
margin-bottom: 10px;
}
.slider-label {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9rem;
}
input[type="range"] {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: rgba(0, 102, 204, 0.3);
border-radius: 4px;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4db8ff;
cursor: pointer;
box-shadow: 0 0 10px #4db8ff;
}
.status-message {
position: absolute;
top: 20px;
right: 260px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 10px 15px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(5px);
}
.status-message.success {
color: #44ff44;
border-left: 3px solid #44ff44;
}
.status-message.warning {
color: #ffff44;
border-left: 3px solid #ffff44;
}
.status-message.error {
color: #ff4444;
border-left: 3px solid #ff4444;
}
.tooltip {
position: absolute;
background-color: rgba(10, 10, 30, 0.95);
border: 1px solid #4db8ff;
border-radius: 5px;
padding: 5px 10px;
font-size: 0.8rem;
color: #fff;
pointer-events: none;
z-index: 100;
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
display: none;
user-select: none;
}
/* Control Toolbar - Stellarium/Star Walk style */
#control-toolbar {
position: relative;
bottom: auto;
right: auto;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(10, 10, 30, 0.9);
border-radius: 999px;
padding: 10px 14px;
border: 1px solid rgba(77, 184, 255, 0.3);
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
transition: all 0.3s ease;
}
.toolbar-items {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: nowrap;
}
.toolbar-divider {
width: 1px;
height: 28px;
background: rgba(77, 184, 255, 0.28);
flex-shrink: 0;
}
.toolbar-btn {
position: relative;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.15);
color: #4db8ff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-sizing: border-box;
padding: 0;
margin: 0;
}
.toolbar-btn:hover {
background: rgba(77, 184, 255, 0.35);
transform: scale(1.1);
box-shadow: 0 0 15px rgba(77, 184, 255, 0.5);
}
.toolbar-btn:active {
transform: scale(0.95);
}
.toolbar-btn.active {
background: rgba(77, 184, 255, 0.4);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
}
.toolbar-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.toolbar-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
#rotate-toggle .icon-play,
#rotate-toggle.is-stopped .icon-pause {
display: none;
}
#rotate-toggle.is-stopped .icon-play {
display: inline-flex;
}
#container.layout-expanded #info-panel {
top: 20px;
left: 20px;
transform: translate(calc(-100% + 20px), calc(-100% + 20px));
}
#container.layout-expanded #coordinates-display {
top: 20px;
right: 20px;
transform: translate(calc(100% - 20px), calc(-100% + 20px));
}
#container.layout-expanded #legend {
left: 20px;
bottom: 20px;
transform: translate(calc(-100% + 20px), calc(100% - 20px));
}
#container.layout-expanded #earth-stats {
right: 20px;
bottom: 20px;
transform: translate(calc(100% - 20px), calc(100% - 20px));
}
#container.layout-expanded #right-toolbar-group {
bottom: 20px;
transform: translateX(-50%);
}
.toolbar-btn .tooltip {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
border: 1px solid rgba(77, 184, 255, 0.4);
pointer-events: none;
z-index: 100;
}
.toolbar-btn:hover .tooltip {
opacity: 1;
visibility: visible;
bottom: 52px;
}
.toolbar-btn .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}

View File

@@ -0,0 +1,421 @@
// controls.js - Zoom, rotate and toggle controls
import { CONFIG, EARTH_CONFIG } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain } from "./earth.js";
import { reloadData, clearLockedObject } from "./main.js";
import {
toggleSatellites,
toggleTrails,
getShowSatellites,
getSatelliteCount,
} from "./satellites.js";
import { toggleCables, getShowCables } from "./cables.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
export let showTerrain = false;
export let isDragging = false;
export let layoutExpanded = false;
let earthObj = null;
let listeners = [];
let cleanupFns = [];
function bindListener(element, eventName, handler, options) {
if (!element) return;
element.addEventListener(eventName, handler, options);
listeners.push(() =>
element.removeEventListener(eventName, handler, options),
);
}
function resetCleanup() {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns = [];
listeners.forEach((cleanup) => cleanup());
listeners = [];
}
export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
earthObj = earth;
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
}
function setupZoomControls(camera) {
let zoomInterval = null;
let holdTimeout = null;
let startTime = 0;
const HOLD_THRESHOLD = 150;
const LONG_PRESS_TICK = 50;
const CLICK_STEP = 10;
const MIN_PERCENT = CONFIG.minZoom * 100;
const MAX_PERCENT = CONFIG.maxZoom * 100;
function doZoomStep(direction) {
let currentPercent = Math.round(zoomLevel * 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);
}
function doContinuousZoom(direction) {
let currentPercent = Math.round(zoomLevel * 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);
}
function startContinuousZoom(direction) {
doContinuousZoom(direction);
zoomInterval = window.setInterval(() => {
doContinuousZoom(direction);
}, LONG_PRESS_TICK);
}
function stopZoom() {
if (zoomInterval) {
clearInterval(zoomInterval);
zoomInterval = null;
}
if (holdTimeout) {
clearTimeout(holdTimeout);
holdTimeout = null;
}
}
function handleMouseDown(direction) {
startTime = Date.now();
stopZoom();
holdTimeout = window.setTimeout(() => {
startContinuousZoom(direction);
}, HOLD_THRESHOLD);
}
function handleMouseUp(direction) {
const heldTime = Date.now() - startTime;
stopZoom();
if (heldTime < HOLD_THRESHOLD) {
doZoomStep(direction);
}
}
cleanupFns.push(stopZoom);
const zoomIn = document.getElementById("zoom-in");
const zoomOut = document.getElementById("zoom-out");
const zoomValue = document.getElementById("zoom-value");
bindListener(zoomIn, "mousedown", () => handleMouseDown(1));
bindListener(zoomIn, "mouseup", () => handleMouseUp(1));
bindListener(zoomIn, "mouseleave", stopZoom);
bindListener(zoomIn, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(1);
});
bindListener(zoomIn, "touchend", () => handleMouseUp(1));
bindListener(zoomOut, "mousedown", () => handleMouseDown(-1));
bindListener(zoomOut, "mouseup", () => handleMouseUp(-1));
bindListener(zoomOut, "mouseleave", stopZoom);
bindListener(zoomOut, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(-1);
});
bindListener(zoomOut, "touchend", () => handleMouseUp(-1));
bindListener(zoomValue, "click", () => {
const startZoomVal = zoomLevel;
const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
animateValue(
0,
1,
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));
},
() => {
zoomLevel = 1.0;
showStatusMessage("缩放已重置到100%", "info");
},
);
});
}
function setupWheelZoom(camera, renderer) {
bindListener(
renderer?.domElement,
"wheel",
(e) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
} else {
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
},
{ 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 startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const current = start + (end - start) * easeProgress;
onUpdate(current);
if (progress < 1) {
requestAnimationFrame(update);
} else if (onComplete) {
onComplete();
}
}
requestAnimationFrame(update);
}
export function resetView(camera) {
if (!earthObj) return;
function animateToView(targetLat, targetLon, targetRotLon) {
const latRot = (targetLat * Math.PI) / 180;
const targetRotX =
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
const targetRotY = -((targetRotLon * Math.PI) / 180);
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const targetZoom = 1.0;
animateValue(
0,
1,
800,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
},
() => {
zoomLevel = 1.0;
showStatusMessage("视角已重置", "info");
},
);
}
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) =>
animateToView(
pos.coords.latitude,
pos.coords.longitude,
-pos.coords.longitude,
),
() =>
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
),
{ timeout: 5000, enableHighAccuracy: false },
);
} else {
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
);
}
clearLockedObject();
}
function setupRotateControls(camera) {
const rotateBtn = document.getElementById("rotate-toggle");
const resetViewBtn = document.getElementById("reset-view");
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
});
updateRotateUI();
bindListener(resetViewBtn, "click", () => {
resetView(camera);
});
}
function setupTerrainControls() {
const container = document.getElementById("container");
const terrainBtn = document.getElementById("toggle-terrain");
const satellitesBtn = document.getElementById("toggle-satellites");
const trailsBtn = document.getElementById("toggle-trails");
const cablesBtn = document.getElementById("toggle-cables");
const layoutBtn = document.getElementById("layout-toggle");
const reloadBtn = document.getElementById("reload-data");
if (trailsBtn) {
trailsBtn.classList.add("active");
const tooltip = trailsBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = "隐藏轨迹";
}
bindListener(terrainBtn, "click", function () {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
this.classList.toggle("active", showTerrain);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showTerrain ? "隐藏地形" : "显示地形";
const terrainStatus = document.getElementById("terrain-status");
if (terrainStatus)
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
});
bindListener(satellitesBtn, "click", function () {
const showSats = !getShowSatellites();
if (!showSats) {
clearLockedObject();
}
toggleSatellites(showSats);
this.classList.toggle("active", showSats);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showSats ? "隐藏卫星" : "显示卫星";
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl)
satelliteCountEl.textContent = getSatelliteCount() + " 颗";
showStatusMessage(showSats ? "卫星已显示" : "卫星已隐藏", "info");
});
bindListener(trailsBtn, "click", function () {
const isActive = this.classList.contains("active");
const nextShowTrails = !isActive;
toggleTrails(nextShowTrails);
this.classList.toggle("active", nextShowTrails);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = nextShowTrails ? "隐藏轨迹" : "显示轨迹";
showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
});
bindListener(cablesBtn, "click", function () {
const showNextCables = !getShowCables();
if (!showNextCables) {
clearLockedObject();
}
toggleCables(showNextCables);
this.classList.toggle("active", showNextCables);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showNextCables ? "隐藏线缆" : "显示线缆";
showStatusMessage(showNextCables ? "线缆已显示" : "线缆已隐藏", "info");
});
bindListener(reloadBtn, "click", async () => {
await reloadData();
});
bindListener(layoutBtn, "click", () => {
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
});
updateLayoutUI(container);
}
export function teardownControls() {
resetCleanup();
}
export function getAutoRotate() {
return autoRotate;
}
function updateRotateUI() {
const btn = document.getElementById("rotate-toggle");
if (btn) {
btn.classList.toggle("active", autoRotate);
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
}
}
export function setAutoRotate(value) {
autoRotate = value;
updateRotateUI();
}
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
clearLockedObject();
return autoRotate;
}
export function getZoomLevel() {
return zoomLevel;
}
export function getShowTerrain() {
return showTerrain;
}
function updateLayoutUI(container) {
if (container) {
container.classList.toggle("layout-expanded", layoutExpanded);
}
const btn = document.getElementById("layout-toggle");
if (btn) {
btn.classList.toggle("active", layoutExpanded);
const tooltip = btn.querySelector(".tooltip");
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
btn.title = nextLabel;
if (tooltip) tooltip.textContent = nextLabel;
}
}
function toggleLayoutExpanded(container) {
layoutExpanded = !layoutExpanded;
updateLayoutUI(container);
return layoutExpanded;
}

View File

@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>智能星球计划 - 现实层宇宙全息感知</title>
<script type="importmap">
{
"imports": {
"three": "https://esm.sh/three@0.128.0",
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
}
}
</script>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/info-panel.css">
<link rel="stylesheet" href="css/coordinates-display.css">
<link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css">
</head>
<body>
<div id="container">
<div id="info-panel">
<h1>智能星球计划</h1>
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
<div id="info-card" class="info-card" style="display: none;">
<div class="info-card-header">
<span class="info-card-icon" id="info-card-icon">🛰️</span>
<h3 id="info-card-title">详情</h3>
</div>
<div id="info-card-content"></div>
</div>
<div id="error-message" class="error-message"></div>
</div>
<div id="right-toolbar-group">
<div id="control-toolbar">
<div class="toolbar-items">
<button id="layout-toggle" class="toolbar-btn" title="最大化布局">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M9 9H5V5"></path>
<path d="M15 9h4V5"></path>
<path d="M9 15H5v4"></path>
<path d="M15 15h4v4"></path>
<path d="M5 5l5 5"></path>
<path d="M19 5l-5 5"></path>
<path d="M5 19l5-5"></path>
<path d="M19 19l-5-5"></path>
</svg>
</span>
<span class="tooltip">最大化布局</span>
</button>
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">
<span class="icon rotate-icon icon-pause" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M9 6v12"></path>
<path d="M15 6v12"></path>
</svg>
</span>
<span class="icon rotate-icon icon-play" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M8 6.5v11l9-5.5z" fill="currentColor" stroke="none"></path>
</svg>
</span>
<span class="tooltip">自动旋转</span>
</button>
<button id="toggle-cables" class="toolbar-btn active" title="显示/隐藏线缆">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="6.5"></circle>
<path d="M5.8 12h12.4"></path>
<path d="M12 5.8a8.5 8.5 0 0 1 0 12.4"></path>
<path d="M8 16c2-1.8 6-1.8 8 0"></path>
</svg>
</span>
<span class="tooltip">隐藏线缆</span>
</button>
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M3 18h18"></path>
<path d="M4.5 18l5-7 3 4 3.5-6 3.5 9"></path>
<path d="M11 18l2-3 1.5 2"></path>
</svg>
</span>
<span class="tooltip">显示/隐藏地形</span>
</button>
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<rect x="10" y="10" width="4" height="4" rx="0.8"></rect>
<rect x="4" y="9" width="4" height="6" rx="0.8"></rect>
<rect x="16" y="9" width="4" height="6" rx="0.8"></rect>
<path d="M8 12h2"></path>
<path d="M14 12h2"></path>
<path d="M12 8V6"></path>
<path d="M11 6h2"></path>
<path d="M12 14v4"></path>
<path d="M10 18h4"></path>
</svg>
</span>
<span class="tooltip">显示卫星</span>
</button>
<button id="toggle-trails" class="toolbar-btn active" title="显示/隐藏轨迹">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M5 17h7"></path>
<path d="M7 13.5h8"></path>
<path d="M10 10h6"></path>
<circle cx="17.5" cy="8.5" r="2.2" fill="currentColor" stroke="none"></circle>
<path d="M15.8 10.2l2.8-2.8"></path>
</svg>
</span>
<span class="tooltip">隐藏轨迹</span>
</button>
<button id="reload-data" class="toolbar-btn" title="重新加载数据">
<span class="icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M20 5v5h-5"></path>
<path d="M20 10a8 8 0 1 0 2 5"></path>
</svg>
</span>
<span class="tooltip">重新加载数据</span>
</button>
</div>
<div class="toolbar-divider" aria-hidden="true"></div>
<div id="zoom-toolbar">
<button id="zoom-out" class="zoom-btn" title="缩小"><span class="tooltip">缩小</span></button>
<span id="zoom-value" class="zoom-percent" title="重置缩放到100%">100%<span class="tooltip">重置缩放到100%</span></span>
<button id="zoom-in" class="zoom-btn" title="放大">+<span class="tooltip">放大</span></button>
<button id="reset-view" class="zoom-btn" title="重置视角">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="5"></circle>
<path d="M12 3v4"></path>
<path d="M12 17v4"></path>
<path d="M3 12h4"></path>
<path d="M17 12h4"></path>
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none"></circle>
</svg>
<span class="tooltip">重置视角</span>
</button>
</div>
</div>
</div>
<div id="coordinates-display">
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
<div class="coord-item">
<span class="coord-label">经度:</span>
<span id="longitude-value" class="coord-value">0.00°</span>
</div>
<div class="coord-item">
<span class="coord-label">纬度:</span>
<span id="latitude-value" class="coord-value">0.00°</span>
</div>
<div id="zoom-level">缩放: 1.0x</div>
<div class="mouse-coords" id="mouse-coords">鼠标位置: 无</div>
</div>
<div id="legend">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">图例</h3>
<div class="legend-item">
<div class="legend-color" style="background-color: #ff4444;"></div>
<span>Americas II</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #44ff44;"></div>
<span>AU Aleutian A</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4444ff;"></div>
<span>AU Aleutian B</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffff44;"></div>
<span>其他电缆</span>
</div>
</div>
<div id="earth-stats">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">地球信息</h3>
<div class="stats-item">
<span class="stats-label">电缆系统:</span>
<span class="stats-value" id="cable-count">0个</span>
</div>
<div class="stats-item">
<span class="stats-label">状态:</span>
<span class="stats-value" id="cable-status-summary">-</span>
</div>
<div class="stats-item">
<span class="stats-label">登陆点:</span>
<span class="stats-value" id="landing-point-count">0个</span>
</div>
<div class="stats-item">
<span class="stats-label">地形:</span>
<span class="stats-value" id="terrain-status">开启</span>
</div>
<div class="stats-item">
<span class="stats-label">卫星:</span>
<span class="stats-value" id="satellite-count">0 颗</span>
</div>
<div class="stats-item">
<span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span>
</div>
<div class="stats-item">
<span class="stats-label">纹理质量:</span>
<span class="stats-value" id="texture-quality">8K 卫星图</span>
</div>
</div>
<div id="loading">
<div id="loading-spinner"></div>
<div id="loading-title">正在初始化全球态势数据...</div>
<div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆与登陆点数据</div>
</div>
<div id="status-message" class="status-message" style="display: none;"></div>
<div id="tooltip" class="tooltip"></div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="6.75" stroke="#4DB8FF" stroke-width="2.1"/>
<path d="M5.75 12H18.25" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
<path d="M12 5.8C14.7 7.75 14.7 16.25 12 18.2" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
<path d="M8 16C9.95 14.2 14.05 14.2 16 16" stroke="#4DB8FF" stroke-width="2.1" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 480 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="8" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M12 10V16" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="7.25" r="1.25" fill="#4DB8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 9L5 5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M5 8V5H8" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 9L19 5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M16 5H19V8" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 15L5 19" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M5 16V19H8" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15 15L19 19" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M16 19H19V16" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 871 B

View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9.2L9.2 9.2L9.2 6" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 9.2L14.8 9.2L14.8 6" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 14.8L9.2 14.8L9.2 18" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 14.8L14.8 14.8L14.8 18" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 6L10 10" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M18 6L14 10" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M6 18L10 14" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M18 18L14 14" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 927 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6.25V17.75" stroke="#4DB8FF" stroke-width="2.4" stroke-linecap="round"/>
<path d="M15 6.25V17.75" stroke="#4DB8FF" stroke-width="2.4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 6.5L17.5 12L8.5 17.5V6.5Z" fill="#4DB8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 163 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.8 12.6C6.8 8.95 9.75 6 13.4 6C14.9 6 16.24 6.46 17.3 7.28" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M18.85 10.45C19.2 11.15 19.4 11.95 19.4 12.8C19.4 16.45 16.45 19.4 12.8 19.4C10.05 19.4 7.69 17.72 6.7 15.33" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M15.9 5.95H19.2V9.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.2 5.95L16.7 8.45" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="5" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M12 3V6.5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M12 17.5V21" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M3 12H6.5" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M17.5 12H21" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="12" r="1.45" fill="#4DB8FF"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View File

@@ -0,0 +1,11 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="4" height="4" rx="0.8" stroke="#4DB8FF" stroke-width="2"/>
<rect x="4" y="9" width="4" height="6" rx="0.8" stroke="#4DB8FF" stroke-width="2"/>
<rect x="16" y="9" width="4" height="6" rx="0.8" stroke="#4DB8FF" stroke-width="2"/>
<path d="M8 12H10" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M14 12H16" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M12 8V6" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M10.75 6H13.25" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M12 14V18" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
<path d="M10.25 18H13.75" stroke="#4DB8FF" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 858 B

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10.5" cy="10.5" r="5.75" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M15.2 15.2L19.25 19.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 18H21" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M4.5 18L9.5 11L12.5 15L16 9L19.5 18" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.25 18L13.05 15.35L14.55 17.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 449 B

View File

@@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 17H12" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M7 13.5H15" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M10 10H16" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="17.5" cy="8.5" r="2.2" fill="#4DB8FF"/>
<path d="M15.8 10.2L18.55 7.45" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10.5" cy="10.5" r="5.75" stroke="#4DB8FF" stroke-width="2.2"/>
<path d="M15.25 15.25L19.25 19.25" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M10.5 8V13" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
<path d="M8 10.5H13" stroke="#4DB8FF" stroke-width="2.2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

View File

@@ -13,6 +13,23 @@ body {
overflow: hidden;
}
:root {
--hud-border: rgba(210, 237, 255, 0.32);
--hud-border-hover: rgba(232, 246, 255, 0.48);
--hud-border-active: rgba(245, 251, 255, 0.62);
--glass-fill-top: rgba(255, 255, 255, 0.18);
--glass-fill-bottom: rgba(115, 180, 255, 0.08);
--glass-sheen: rgba(255, 255, 255, 0.34);
--glass-shadow: 0 14px 30px rgba(0, 0, 0, 0.22);
--glass-glow: 0 0 26px rgba(120, 200, 255, 0.16);
}
@property --float-offset {
syntax: '<length>';
inherits: false;
initial-value: 0px;
}
#container {
position: relative;
width: 100vw;
@@ -23,85 +40,103 @@ body {
cursor: grabbing;
}
/* Right Toolbar Group */
/* Bottom Dock */
#right-toolbar-group {
position: absolute;
bottom: 20px;
right: 290px;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex-direction: row;
align-items: center;
justify-content: center;
z-index: 200;
}
/* Zoom Toolbar - Right side, vertical */
#zoom-toolbar {
#right-toolbar-group,
#info-panel,
#coordinates-display,
#legend,
#earth-stats {
transition:
top 0.45s ease,
right 0.45s ease,
bottom 0.45s ease,
left 0.45s ease,
transform 0.45s ease,
box-shadow 0.45s ease;
}
#info-panel,
#coordinates-display,
#legend,
#earth-stats,
#satellite-info {
position: absolute;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 24% 12%, rgba(255, 255, 255, 0.12), transparent 28%),
radial-gradient(circle at 78% 115%, rgba(255, 255, 255, 0.06), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(110, 176, 255, 0.06)),
rgba(7, 18, 36, 0.28);
border: 1px solid rgba(225, 242, 255, 0.2);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(255, 255, 255, 0.04),
0 18px 40px rgba(0, 0, 0, 0.24),
0 0 32px rgba(120, 200, 255, 0.12);
backdrop-filter: blur(20px) saturate(145%);
-webkit-backdrop-filter: blur(20px) saturate(145%);
}
#info-panel::before,
#coordinates-display::before,
#legend::before,
#earth-stats::before,
#satellite-info::before {
content: '';
position: absolute;
inset: 1px 1px 24% 1px;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 26%, transparent 70%);
opacity: 0.46;
pointer-events: none;
}
#info-panel::after,
#coordinates-display::after,
#legend::after,
#earth-stats::after,
#satellite-info::after {
content: '';
position: absolute;
inset: -1px;
padding: 1.4px;
border-radius: inherit;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(170, 223, 255, 0.2) 34%, rgba(88, 169, 255, 0.14) 68%, rgba(255, 255, 255, 0.24));
opacity: 0.78;
pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
#info-panel > *,
#coordinates-display > *,
#legend > *,
#earth-stats > *,
#satellite-info > * {
position: relative;
bottom: auto;
right: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: rgba(10, 10, 30, 0.9);
padding: 8px 4px;
border-radius: 24px;
border: 1px solid rgba(77, 184, 255, 0.3);
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
}
#zoom-toolbar #zoom-slider {
width: 4px;
height: 50px;
margin: 4px 0;
writing-mode: vertical-lr;
direction: rtl;
-webkit-appearance: slider-vertical;
}
#zoom-toolbar .zoom-percent {
font-size: 0.75rem;
font-weight: 600;
color: #4db8ff;
min-width: 30px;
text-align: center;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
#zoom-toolbar .zoom-percent:hover {
background: rgba(77, 184, 255, 0.2);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
}
#zoom-toolbar .zoom-btn {
width: 28px;
height: 28px;
min-width: 28px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.2);
color: #4db8ff;
font-size: 14px;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
margin: 0;
flex: 0 0 auto;
box-sizing: border-box;
}
#zoom-toolbar .zoom-btn:hover {
background: rgba(77, 184, 255, 0.4);
transform: scale(1.1);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
z-index: 1;
}
#loading {
@@ -185,16 +220,28 @@ input[type="range"]::-webkit-slider-thumb {
.status-message {
position: absolute;
top: 20px;
right: 260px;
left: 50%;
transform: translate(-50%, -18px);
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
padding: 10px 15px;
z-index: 10;
z-index: 210;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
display: none;
backdrop-filter: blur(5px);
text-align: center;
min-width: 180px;
opacity: 0;
transition:
transform 0.28s ease,
opacity 0.28s ease;
}
.status-message.visible {
transform: translate(-50%, 0);
opacity: 1;
}
.status-message.success {
@@ -227,71 +274,172 @@ input[type="range"]::-webkit-slider-thumb {
user-select: none;
}
/* Control Toolbar - Stellarium/Star Walk style */
/* Floating toolbar dock */
#control-toolbar {
position: relative;
bottom: auto;
right: auto;
display: flex;
align-items: center;
background: rgba(10, 10, 30, 0.9);
border-radius: 24px;
padding: 8px;
border: 1px solid rgba(77, 184, 255, 0.3);
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
transition: all 0.3s ease;
}
#control-toolbar.collapsed {
padding: 8px;
}
#control-toolbar.collapsed .toolbar-items {
width: 0;
padding: 0;
margin: 0;
overflow: hidden;
opacity: 0;
}
#toolbar-toggle {
min-width: 28px;
line-height: 1;
transition: all 0.3s ease;
flex-shrink: 0;
justify-content: center;
gap: 0;
background: transparent;
border: none;
}
.toggle-arrow {
font-size: 14px;
color: #4db8ff;
transition: transform 0.3s ease;
}
#control-toolbar.collapsed .toggle-arrow {
transform: rotate(0deg);
}
#control-toolbar:not(.collapsed) .toggle-arrow {
transform: rotate(180deg);
}
#control-toolbar.collapsed #toolbar-toggle {
background: transparent;
box-shadow: none;
padding: 0;
}
.toolbar-items {
display: flex;
gap: 6px;
width: auto;
padding: 0 4px 0 2px;
overflow: visible;
opacity: 1;
transition: all 0.3s ease;
border-right: 1px solid rgba(77, 184, 255, 0.3);
margin-right: 4px;
flex-shrink: 0;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
.floating-popover-group {
position: relative;
}
.floating-popover-group::before {
content: '';
position: absolute;
left: 50%;
bottom: 100%;
transform: translateX(-50%);
width: 56px;
height: 16px;
background: transparent;
}
.floating-popover-group > .stack-toolbar {
position: absolute;
left: 50%;
top: auto;
right: auto;
bottom: calc(100% + 12px);
transform: translate(-50%, 10px);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.22s ease,
transform 0.22s ease,
visibility 0.22s ease;
z-index: 220;
}
.toolbar-btn.floating-btn {
width: 42px;
height: 42px;
min-width: 42px;
min-height: 42px;
border-radius: 50%;
overflow: hidden;
}
.liquid-glass-surface {
--elastic-x: 0px;
--elastic-y: 0px;
--tilt-x: 0deg;
--tilt-y: 0deg;
--btn-scale: 1;
--press-offset: 0px;
--float-offset: 0px;
--glow-opacity: 0.24;
--glow-x: 50%;
--glow-y: 22%;
position: relative;
isolation: isolate;
transform-style: preserve-3d;
overflow: hidden;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.16), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, var(--glass-fill-top), var(--glass-fill-bottom)),
rgba(8, 20, 38, 0.22);
border: 1px solid var(--hud-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.14),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
var(--glass-shadow),
var(--glass-glow);
backdrop-filter: blur(18px) saturate(145%);
-webkit-backdrop-filter: blur(18px) saturate(145%);
transform:
translate3d(var(--elastic-x), calc(var(--float-offset) + var(--press-offset) + var(--elastic-y)), 0)
scale(var(--btn-scale));
transition:
transform 0.22s ease,
box-shadow 0.22s ease,
background 0.22s ease,
opacity 0.18s ease,
border-color 0.22s ease;
animation: floatDock 3.8s ease-in-out infinite;
}
.liquid-glass-surface::before {
content: '';
position: absolute;
inset: 1px 1px 18px 1px;
border-radius: inherit;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.05) 28%, transparent 68%);
opacity: 0.5;
pointer-events: none;
transform:
perspective(120px)
rotateX(calc(var(--tilt-x) * 0.7))
rotateY(calc(var(--tilt-y) * 0.7))
translate3d(calc(var(--elastic-x) * 0.22), calc(var(--elastic-y) * 0.22), 0);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.liquid-glass-surface::after {
content: '';
position: absolute;
inset: -1px;
padding: 1.35px;
border-radius: inherit;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.36), rgba(168, 222, 255, 0.22) 34%, rgba(96, 175, 255, 0.16) 66%, rgba(255, 255, 255, 0.28));
opacity: 0.82;
pointer-events: none;
filter: url(#liquid-glass-distortion) blur(0.35px);
transform:
perspective(120px)
rotateX(calc(var(--tilt-x) * 0.5))
rotateY(calc(var(--tilt-y) * 0.5))
translate3d(calc(var(--elastic-x) * 0.16), calc(var(--elastic-y) * 0.16), 0);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toolbar-items > :nth-child(2n).floating-btn,
.toolbar-items > :nth-child(2n) .floating-btn {
animation-delay: 0.18s;
}
.toolbar-items > :nth-child(3n).floating-btn,
.toolbar-items > :nth-child(3n) .floating-btn {
animation-delay: 0.34s;
}
@keyframes floatDock {
0%, 100% {
--float-offset: 0px;
}
50% {
--float-offset: -4px;
}
}
.toolbar-btn {
@@ -299,38 +447,309 @@ input[type="range"]::-webkit-slider-thumb {
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(77, 184, 255, 0.15);
border-radius: 0;
background: transparent;
color: #4db8ff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-sizing: border-box;
padding: 0;
margin: 0;
overflow: visible;
appearance: none;
-webkit-appearance: none;
}
.toolbar-btn:hover {
background: rgba(77, 184, 255, 0.35);
transform: scale(1.1);
box-shadow: 0 0 15px rgba(77, 184, 255, 0.5);
.toolbar-btn:not(.liquid-glass-surface)::after {
content: none;
}
.toolbar-btn:active {
transform: scale(0.95);
.toolbar-btn .icon {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
transform: translateZ(0);
transition: transform 0.16s ease, opacity 0.16s ease;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
line-height: 1;
}
.toolbar-btn.active {
background: rgba(77, 184, 255, 0.4);
box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
.liquid-glass-surface:hover {
--btn-scale: 1.035;
--press-offset: -1px;
--glow-opacity: 0.32;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.1), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(128, 198, 255, 0.1)),
rgba(8, 20, 38, 0.2);
border-color: var(--hud-border-hover);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.08),
0 18px 36px rgba(0, 0, 0, 0.24),
0 0 28px rgba(145, 214, 255, 0.22);
}
.liquid-glass-surface:hover::before {
opacity: 0.62;
transform: scale(1.01);
}
.liquid-glass-surface:hover::after {
opacity: 0.96;
transform: scale(1.01);
}
.liquid-glass-surface:active,
.liquid-glass-surface.is-pressed {
--btn-scale: 0.942;
--press-offset: 2px;
--glow-opacity: 0.2;
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.24), transparent 34%),
radial-gradient(circle at 50% 118%, rgba(255, 255, 255, 0.14), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.24), rgba(146, 210, 255, 0.16)),
rgba(10, 24, 44, 0.24);
border-color: rgba(240, 249, 255, 0.58);
box-shadow:
inset 0 2px 10px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.16),
0 4px 10px rgba(0, 0, 0, 0.18),
0 0 14px rgba(176, 226, 255, 0.18);
}
.liquid-glass-surface:active::before,
.liquid-glass-surface.is-pressed::before {
opacity: 0.46;
transform: translateY(2px) scale(0.985);
}
.liquid-glass-surface:active::after,
.liquid-glass-surface.is-pressed::after {
opacity: 0.78;
transform: scale(0.985);
}
.liquid-glass-surface:active .icon,
.liquid-glass-surface.is-pressed .icon {
transform: translateY(1.5px);
}
.liquid-glass-surface:active img,
.liquid-glass-surface.is-pressed img,
.liquid-glass-surface:active .material-symbols-rounded,
.liquid-glass-surface.is-pressed .material-symbols-rounded {
transform: translateY(1.5px);
transition: transform 0.16s ease, opacity 0.16s ease;
}
#zoom-control-group #zoom-toolbar .zoom-btn:active,
#zoom-control-group #zoom-toolbar .zoom-btn.is-pressed,
#zoom-control-group #zoom-toolbar .zoom-percent:active,
#zoom-control-group #zoom-toolbar .zoom-percent.is-pressed {
letter-spacing: -0.01em;
}
.liquid-glass-surface.active {
background:
radial-gradient(circle at var(--glow-x) var(--glow-y), rgba(255, 255, 255, 0.18), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(118, 200, 255, 0.14)),
rgba(11, 34, 58, 0.26);
border-color: var(--hud-border-active);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 0 18px rgba(160, 220, 255, 0.14),
0 18px 34px rgba(0, 0, 0, 0.24),
0 0 30px rgba(145, 214, 255, 0.24);
}
.toolbar-btn svg {
width: 20px;
height: 20px;
stroke: currentColor;
stroke-width: 2.1;
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.toolbar-btn .material-symbols-rounded {
font-size: 21px;
line-height: 1;
font-variation-settings:
'FILL' 0,
'wght' 500,
'GRAD' 0,
'opsz' 24;
color: currentColor;
display: inline-flex;
align-items: center;
justify-content: center;
user-select: none;
pointer-events: none;
text-rendering: geometricPrecision;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.toolbar-btn img {
width: 20px;
height: 20px;
display: block;
user-select: none;
pointer-events: none;
shape-rendering: geometricPrecision;
image-rendering: -webkit-optimize-contrast;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
#rotate-toggle .icon-play,
#rotate-toggle.is-stopped .icon-pause,
#layout-toggle .layout-collapse,
#layout-toggle.active .layout-expand {
display: none;
}
#rotate-toggle.is-stopped .icon-play,
#layout-toggle.active .layout-collapse {
display: inline-flex;
}
#zoom-control-group:hover #zoom-toolbar,
#zoom-control-group:focus-within #zoom-toolbar,
#zoom-control-group.open #zoom-toolbar,
#info-control-group:hover #info-toolbar,
#info-control-group:focus-within #info-toolbar,
#info-control-group.open #info-toolbar {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translate(-50%, 0);
}
#zoom-control-group #zoom-toolbar .zoom-percent {
min-width: 0;
width: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 42px;
padding: 0;
font-size: 0.68rem;
border-radius: 50%;
color: #4db8ff;
animation: floatDock 3.8s ease-in-out infinite;
animation-delay: 0.18s;
}
#zoom-control-group #zoom-toolbar .zoom-percent:hover {
}
#zoom-control-group #zoom-toolbar,
#info-control-group #info-toolbar {
top: auto;
right: auto;
left: 50%;
bottom: calc(100% + 12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
#info-toolbar .toolbar-btn:nth-child(1) {
animation-delay: 0.34s;
}
#info-toolbar .toolbar-btn:nth-child(2) {
animation-delay: 0.18s;
}
#info-toolbar .toolbar-btn:nth-child(3) {
animation-delay: 0.1s;
}
#info-toolbar .toolbar-btn:nth-child(4) {
animation-delay: 0s;
}
#zoom-control-group #zoom-toolbar .zoom-btn {
width: 42px;
height: 42px;
min-width: 42px;
border-radius: 50%;
color: #4db8ff;
animation: floatDock 3.8s ease-in-out infinite;
}
#zoom-toolbar .zoom-btn:nth-child(1) {
animation-delay: 0s;
}
#zoom-toolbar .zoom-btn:nth-child(3) {
animation-delay: 0.34s;
}
#zoom-control-group #zoom-toolbar .zoom-btn:hover {
}
#zoom-control-group #zoom-toolbar .zoom-btn:active,
#zoom-control-group #zoom-toolbar .zoom-percent:active {
}
#zoom-control-group #zoom-toolbar .tooltip {
bottom: calc(100% + 10px);
}
#zoom-control-group #zoom-toolbar .tooltip::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(77, 184, 255, 0.4);
}
#container.layout-expanded #info-panel {
top: 20px;
left: 20px;
transform: translate(calc(-100% + 20px), calc(-100% + 20px));
}
#container.layout-expanded #coordinates-display {
top: 20px;
right: 20px;
transform: translate(calc(100% - 20px), calc(-100% + 20px));
}
#container.layout-expanded #legend {
left: 20px;
bottom: 20px;
transform: translate(calc(-100% + 20px), calc(100% - 20px));
}
#container.layout-expanded #earth-stats {
right: 20px;
bottom: 20px;
transform: translate(calc(100% - 20px), calc(100% - 20px));
}
#container.layout-expanded #right-toolbar-group {
bottom: 18px;
transform: translateX(-50%);
}
.toolbar-btn .tooltip {
position: absolute;
bottom: 50px;
bottom: 56px;
left: 50%;
transform: translateX(-50%);
background: rgba(10, 10, 30, 0.95);
@@ -347,10 +766,12 @@ input[type="range"]::-webkit-slider-thumb {
z-index: 100;
}
.toolbar-btn:hover .tooltip {
.toolbar-btn:hover .tooltip,
.floating-popover-group:hover > .toolbar-btn .tooltip,
.floating-popover-group:focus-within > .toolbar-btn .tooltip {
opacity: 1;
visibility: visible;
bottom: 52px;
bottom: 58px;
}
.toolbar-btn .tooltip::after {

View File

@@ -1,18 +1,13 @@
/* coordinates-display */
#coordinates-display {
position: absolute;
top: 20px;
right: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
border-radius: 18px;
padding: 10px 15px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
min-width: 180px;
backdrop-filter: blur(5px);
}
#coordinates-display .coord-item {

View File

@@ -1,18 +1,13 @@
/* earth-stats */
#earth-stats {
position: absolute;
bottom: 20px;
right: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
border-radius: 18px;
padding: 15px;
width: 250px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
font-size: 0.9rem;
backdrop-filter: blur(5px);
}
#earth-stats .stats-item {
@@ -31,18 +26,13 @@
}
#satellite-info {
position: absolute;
bottom: 20px;
right: 290px;
background-color: rgba(10, 10, 30, 0.9);
border-radius: 10px;
border-radius: 18px;
padding: 15px;
width: 220px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 229, 255, 0.3);
border: 1px solid rgba(0, 229, 255, 0.3);
font-size: 0.85rem;
backdrop-filter: blur(5px);
}
#satellite-info .stats-item {

View File

@@ -1,17 +1,12 @@
/* info-panel */
#info-panel {
position: absolute;
top: 20px;
left: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
border-radius: 18px;
padding: 20px;
width: 320px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
backdrop-filter: blur(5px);
}
#info-panel h1 {
@@ -19,14 +14,34 @@
margin-bottom: 5px;
color: #4db8ff;
text-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
text-align: center;
}
#info-panel .subtitle {
color: #aaa;
margin-bottom: 20px;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
padding-bottom: 10px;
padding-bottom: 12px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
#info-panel .subtitle-main {
color: #d7e7f5;
font-size: 0.95rem;
line-height: 1.35;
font-weight: 500;
letter-spacing: 0.02em;
}
#info-panel .subtitle-meta {
color: #8ea5bc;
font-size: 0.74rem;
line-height: 1.3;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#info-panel .cable-info {
@@ -159,8 +174,14 @@
/* Info Card - Unified details panel (inside info-panel) */
.info-card {
margin-top: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(110, 176, 255, 0.04)),
rgba(7, 18, 36, 0.2);
border-radius: 14px;
border: 1px solid rgba(225, 242, 255, 0.12);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 10px 24px rgba(0, 0, 0, 0.16);
padding: 0;
overflow: hidden;
}
@@ -174,7 +195,7 @@
display: flex;
align-items: center;
padding: 10px 12px;
background: rgba(77, 184, 255, 0.1);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(77, 184, 255, 0.06));
gap: 8px;
}
@@ -189,16 +210,35 @@
color: #4db8ff;
}
.info-card-content {
#info-card-content {
padding: 10px 12px;
max-height: 200px;
max-height: 40vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(160, 220, 255, 0.45) transparent;
}
#info-card-content::-webkit-scrollbar {
width: 6px;
}
#info-card-content::-webkit-scrollbar-track {
background: transparent;
}
#info-card-content::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.32), rgba(110, 176, 255, 0.34));
border-radius: 999px;
}
#info-card-content::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(232, 246, 255, 0.42), rgba(128, 198, 255, 0.46));
}
.info-card-property {
display: flex;
justify-content: space-between;
padding: 6px 0;
padding: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
@@ -209,6 +249,12 @@
.info-card-label {
color: #aaa;
font-size: 0.85rem;
cursor: pointer;
transition: color 0.18s ease;
}
.info-card-label:hover {
color: #d9f1ff;
}
.info-card-value {

View File

@@ -1,28 +1,59 @@
/* legend */
#legend {
position: absolute;
bottom: 20px;
left: 20px;
background-color: rgba(10, 10, 30, 0.85);
border-radius: 10px;
border-radius: 18px;
padding: 15px;
width: 220px;
z-index: 10;
box-shadow: 0 0 20px rgba(0, 150, 255, 0.3);
border: 1px solid rgba(0, 150, 255, 0.2);
backdrop-filter: blur(5px);
}
#legend .legend-title {
color: #4db8ff;
margin-bottom: 10px;
font-size: 1.1rem;
}
#legend .legend-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 202px;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(160, 220, 255, 0.4) transparent;
}
#legend .legend-list::-webkit-scrollbar {
width: 6px;
}
#legend .legend-list::-webkit-scrollbar-track {
background: transparent;
}
#legend .legend-list::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(210, 237, 255, 0.28), rgba(110, 176, 255, 0.34));
border-radius: 999px;
}
#legend .legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
padding: 6px 8px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(120, 180, 255, 0.02));
border: 1px solid rgba(225, 242, 255, 0.06);
}
#legend .legend-color {
width: 20px;
height: 20px;
border-radius: 3px;
border-radius: 6px;
margin-right: 10px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 0 12px rgba(77, 184, 255, 0.18);
}

View File

@@ -18,12 +18,25 @@
<link rel="stylesheet" href="css/coordinates-display.css">
<link rel="stylesheet" href="css/legend.css">
<link rel="stylesheet" href="css/earth-stats.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,500,0,0">
</head>
<body>
<svg aria-hidden="true" width="0" height="0" style="position:absolute; width:0; height:0; pointer-events:none;">
<defs>
<filter id="liquid-glass-distortion" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="2" seed="7" result="noise" />
<feGaussianBlur in="noise" stdDeviation="0.45" result="softNoise" />
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="5.5" xChannelSelector="R" yChannelSelector="G" />
</filter>
</defs>
</svg>
<div id="container">
<div id="info-panel">
<h1>智能星球计划</h1>
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
<div class="subtitle">
<span class="subtitle-main">现实层宇宙全息感知系统</span>
<span class="subtitle-meta">卫星 · 海底光缆 · 算力基础设施</span>
</div>
<div id="info-card" class="info-card" style="display: none;">
<div class="info-card-header">
@@ -37,23 +50,98 @@
</div>
<div id="right-toolbar-group">
<div id="zoom-toolbar">
<button id="reset-view" class="zoom-btn">🎯</button>
<button id="zoom-in" class="zoom-btn">+</button>
<span id="zoom-value" class="zoom-percent">100%</span>
<button id="zoom-out" class="zoom-btn"></button>
</div>
<div id="control-toolbar">
<div class="toolbar-items">
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">🔄<span class="tooltip">自动旋转</span></button>
<button id="toggle-cables" class="toolbar-btn active" title="显示/隐藏线缆">🌐<span class="tooltip">隐藏线缆</span></button>
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">⛰️<span class="tooltip">显示/隐藏地形</span></button>
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">🛰️<span class="tooltip">显示卫星</span></button>
<button id="toggle-trails" class="toolbar-btn" title="显示/隐藏轨迹"><span class="tooltip">显示/隐藏轨迹</span></button>
<button id="reload-data" class="toolbar-btn" title="重新加载数据">🔃<span class="tooltip">重新加载数据</span></button>
<button id="search-action" class="toolbar-btn floating-btn liquid-glass-surface" title="搜索功能(待开发)">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">search</span>
</span>
<span class="tooltip">搜索功能(待开发)</span>
</button>
<button id="rotate-toggle" class="toolbar-btn floating-btn liquid-glass-surface" title="自动旋转">
<span class="icon rotate-icon icon-pause" aria-hidden="true">
<span class="material-symbols-rounded">pause</span>
</span>
<span class="icon rotate-icon icon-play" aria-hidden="true">
<span class="material-symbols-rounded">play_arrow</span>
</span>
<span class="tooltip">自动旋转</span>
</button>
<div id="info-control-group" class="floating-popover-group">
<button id="info-trigger" class="toolbar-btn floating-btn liquid-glass-surface" title="显示控制">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">info</span>
</span>
<span class="tooltip">显示控制</span>
</button>
<div id="info-toolbar" class="stack-toolbar">
<button id="toggle-terrain" class="toolbar-btn floating-btn liquid-glass-surface" title="显示/隐藏地形">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">terrain</span>
</span>
<span class="tooltip">显示/隐藏地形</span>
</button>
<button id="toggle-trails" class="toolbar-btn floating-btn liquid-glass-surface active" title="显示/隐藏轨迹">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">timeline</span>
</span>
<span class="tooltip">隐藏轨迹</span>
</button>
<button id="toggle-satellites" class="toolbar-btn floating-btn liquid-glass-surface" title="显示/隐藏卫星">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">satellite_alt</span>
</span>
<span class="tooltip">显示卫星</span>
</button>
<button id="toggle-bgp" class="toolbar-btn floating-btn liquid-glass-surface active" title="显示/隐藏BGP观测">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">radar</span>
</span>
<span class="tooltip">隐藏BGP观测</span>
</button>
<button id="toggle-cables" class="toolbar-btn floating-btn liquid-glass-surface active" title="显示/隐藏线缆">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">cable</span>
</span>
<span class="tooltip">隐藏线缆</span>
</button>
</div>
</div>
<button id="reload-data" class="toolbar-btn floating-btn liquid-glass-surface" title="重新加载数据">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">refresh</span>
</span>
<span class="tooltip">重新加载数据</span>
</button>
<div id="zoom-control-group" class="floating-popover-group">
<button id="zoom-trigger" class="toolbar-btn floating-btn liquid-glass-surface" title="缩放控制">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">zoom_in</span>
</span>
<span class="tooltip">缩放控制</span>
</button>
<div id="zoom-toolbar" class="stack-toolbar">
<button id="zoom-in" class="zoom-btn liquid-glass-surface" title="放大">+<span class="tooltip">放大</span></button>
<span id="zoom-value" class="zoom-percent liquid-glass-surface" title="重置缩放到100%">100%<span class="tooltip">重置缩放到100%</span></span>
<button id="zoom-out" class="zoom-btn liquid-glass-surface" title="缩小"><span class="tooltip">缩小</span></button>
</div>
</div>
<button id="reset-view" class="toolbar-btn floating-btn liquid-glass-surface" title="重置视角">
<span class="icon" aria-hidden="true">
<span class="material-symbols-rounded">my_location</span>
</span>
<span class="tooltip">重置视角</span>
</button>
<button id="layout-toggle" class="toolbar-btn floating-btn liquid-glass-surface" title="最大化布局">
<span class="icon layout-icon layout-expand" aria-hidden="true">
<span class="material-symbols-rounded">open_in_full</span>
</span>
<span class="icon layout-icon layout-collapse" aria-hidden="true">
<span class="material-symbols-rounded">close_fullscreen</span>
</span>
<span class="tooltip">最大化布局</span>
</button>
</div>
<button id="toolbar-toggle" class="toolbar-btn" title="展开/收起工具栏"><span class="toggle-arrow"></span></button>
</div>
</div>
@@ -72,22 +160,24 @@
</div>
<div id="legend">
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">图例</h3>
<div class="legend-item">
<div class="legend-color" style="background-color: #ff4444;"></div>
<span>Americas II</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #44ff44;"></div>
<span>AU Aleutian A</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4444ff;"></div>
<span>AU Aleutian B</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffff44;"></div>
<span>其他电缆</span>
<h3 class="legend-title">线缆图例</h3>
<div class="legend-list">
<div class="legend-item">
<div class="legend-color" style="background-color: #ff4444;"></div>
<span>Americas II</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #44ff44;"></div>
<span>AU Aleutian A</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #4444ff;"></div>
<span>AU Aleutian B</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #ffff44;"></div>
<span>其他电缆</span>
</div>
</div>
</div>
@@ -113,6 +203,10 @@
<span class="stats-label">卫星:</span>
<span class="stats-value" id="satellite-count">0 颗</span>
</div>
<div class="stats-item">
<span class="stats-label">BGP异常:</span>
<span class="stats-value" id="bgp-anomaly-count">0 条</span>
</div>
<div class="stats-item">
<span class="stats-label">视角距离:</span>
<span class="stats-value" id="camera-distance">300 km</span>
@@ -125,8 +219,8 @@
<div id="loading">
<div id="loading-spinner"></div>
<div>正在加载3D地球和电缆数据...</div>
<div style="font-size:0.9rem; margin-top:10px; color:#aaa;">使用8K高分辨率卫星纹理 | 大陆轮廓更清晰</div>
<div id="loading-title">正在初始化全球态势数据...</div>
<div id="loading-subtitle" style="font-size:0.9rem; margin-top:10px; color:#aaa;">同步卫星、海底光缆、登陆点与BGP异常数据</div>
</div>
<div id="status-message" class="status-message" style="display: none;"></div>
<div id="tooltip" class="tooltip"></div>

View File

@@ -0,0 +1,787 @@
import * as THREE from "three";
import { BGP_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const bgpGroup = new THREE.Group();
const bgpOverlayGroup = new THREE.Group();
const collectorMarkers = [];
const anomalyMarkers = [];
const anomalyCountByCollector = new Map();
let showBGP = true;
let totalAnomalyCount = 0;
let textureCache = null;
let activeEventOverlay = null;
const relativeTimeFormatter = new Intl.RelativeTimeFormat("zh-CN", {
numeric: "auto",
});
function getMarkerTexture() {
if (textureCache) return textureCache;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
textureCache = new THREE.Texture(canvas);
return textureCache;
}
const gradient = context.createRadialGradient(64, 64, 8, 64, 64, 56);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.24, "rgba(255,255,255,0.92)");
gradient.addColorStop(0.58, "rgba(255,255,255,0.35)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = gradient;
context.beginPath();
context.arc(64, 64, 56, 0, Math.PI * 2);
context.fill();
textureCache = new THREE.CanvasTexture(canvas);
return textureCache;
}
function normalizeSeverity(severity) {
const value = String(severity || "").trim().toLowerCase();
if (value === "critical") return "critical";
if (value === "high" || value === "major") return "high";
if (value === "medium" || value === "moderate" || value === "warning") {
return "medium";
}
if (value === "low" || value === "info" || value === "informational") {
return "low";
}
return "medium";
}
function getSeverityColor(severity) {
return BGP_CONFIG.severityColors[normalizeSeverity(severity)];
}
function getSeverityScale(severity) {
return BGP_CONFIG.severityScales[normalizeSeverity(severity)];
}
function formatLocalDateTime(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(date.getSeconds()).padStart(2, "0")}`;
}
function toDate(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function formatRelativeTime(value) {
const date = toDate(value);
if (!date) return null;
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 60 * 1000) {
return relativeTimeFormatter.format(Math.round(diffMs / 1000), "second");
}
if (absMs < 60 * 60 * 1000) {
return relativeTimeFormatter.format(Math.round(diffMs / (60 * 1000)), "minute");
}
if (absMs < 24 * 60 * 60 * 1000) {
return relativeTimeFormatter.format(Math.round(diffMs / (60 * 60 * 1000)), "hour");
}
return relativeTimeFormatter.format(
Math.round(diffMs / (24 * 60 * 60 * 1000)),
"day",
);
}
export function formatBGPSeverityLabel(severity) {
const normalized = normalizeSeverity(severity);
switch (normalized) {
case "critical":
return "严重";
case "high":
return "高";
case "medium":
return "中";
case "low":
return "低";
default:
return "中";
}
}
export function formatBGPAnomalyTypeLabel(type) {
const value = String(type || "").trim().toLowerCase();
if (!value) return "-";
if (value.includes("hijack")) return "前缀劫持";
if (value.includes("leak")) return "路由泄露";
if (value.includes("withdraw")) return "大规模撤销";
if (value.includes("subprefix") || value.includes("more_specific")) {
return "更具体前缀异常";
}
if (value.includes("path")) return "路径突变";
if (value.includes("flap")) return "路由抖动";
return String(type);
}
export function formatBGPStatusLabel(status) {
const value = String(status || "").trim().toLowerCase();
if (!value) return "-";
if (value === "active") return "活跃";
if (value === "resolved") return "已恢复";
if (value === "suppressed") return "已抑制";
return String(status);
}
export function formatBGPCollectorStatus(status) {
const value = String(status || "").trim().toLowerCase();
if (!value) return "在线";
if (value === "online") return "在线";
if (value === "offline") return "离线";
return String(status);
}
export function formatBGPConfidence(value) {
if (value === null || value === undefined || value === "") return "-";
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
if (number >= 0 && number <= 1) {
return `${Math.round(number * 100)}%`;
}
return `${Math.round(number)}%`;
}
export function formatBGPLocation(city, country) {
const cityText = city || "";
const countryText = country || "";
if (cityText && countryText) return `${cityText}, ${countryText}`;
return cityText || countryText || "-";
}
export function formatBGPRouteChange(originAsn, newOriginAsn) {
const from = originAsn ?? "-";
const to = newOriginAsn ?? "-";
if ((from === "-" || from === "" || from === null) && (to === "-" || to === "" || to === null)) {
return "-";
}
if (to === "-" || to === "" || to === null) {
return `AS${from}`;
}
return `AS${from} -> AS${to}`;
}
export function formatBGPObservedTime(value) {
const absolute = formatLocalDateTime(value);
const relative = formatRelativeTime(value);
if (!relative || absolute === "-") return absolute;
return `${relative} (${absolute})`;
}
export function formatBGPASPath(asPath) {
if (!Array.isArray(asPath) || asPath.length === 0) return "-";
return asPath.map((asn) => `AS${asn}`).join(" -> ");
}
export function formatBGPObservedBy(collectors) {
if (!Array.isArray(collectors) || collectors.length === 0) return "-";
const preview = collectors.slice(0, 3).join(", ");
if (collectors.length <= 3) {
return `${collectors.length}个观测站 (${preview})`;
}
return `${collectors.length}个观测站 (${preview} 等)`;
}
export function formatBGPImpactedScope(regions) {
if (!Array.isArray(regions) || regions.length === 0) return "-";
const labels = regions
.map((region) => {
const city = region?.city || "";
const country = region?.country || "";
return city && country ? `${city}, ${country}` : city || country || "";
})
.filter(Boolean);
if (labels.length === 0) return "-";
if (labels.length <= 3) return labels.join(" / ");
return `${labels.slice(0, 3).join(" / ")}${labels.length}`;
}
function buildCollectorFeatureData(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
const properties = feature?.properties || {};
return {
latitude,
longitude,
collector: properties.collector || "-",
city: properties.city || "-",
country: properties.country || "-",
status: properties.status || "online",
};
}
function spreadCollectorPositions(markers) {
const groups = new Map();
markers.forEach((marker) => {
const key = `${marker.latitude.toFixed(4)}|${marker.longitude.toFixed(4)}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(marker);
});
groups.forEach((group) => {
if (group.length <= 1) return;
const radius = 0.9;
group.forEach((marker, index) => {
const angle = (Math.PI * 2 * index) / group.length;
marker.displayLatitude =
marker.latitude + Math.sin(angle) * radius * 0.18;
marker.displayLongitude =
marker.longitude + Math.cos(angle) * radius * 0.18;
marker.isSpread = true;
marker.groupSize = group.length;
});
});
markers.forEach((marker) => {
if (marker.displayLatitude === undefined) {
marker.displayLatitude = marker.latitude;
marker.displayLongitude = marker.longitude;
marker.isSpread = false;
marker.groupSize = 1;
}
});
return markers;
}
function buildAnomalyFeatureData(feature) {
const coordinates = feature?.geometry?.coordinates || [];
const [longitude, latitude] = coordinates;
if (
typeof latitude !== "number" ||
typeof longitude !== "number" ||
Number.isNaN(latitude) ||
Number.isNaN(longitude)
) {
return null;
}
const properties = feature?.properties || {};
const severity = normalizeSeverity(properties.severity);
const createdAt = properties.created_at || null;
return {
latitude,
longitude,
rawSeverity: properties.severity || severity,
severity,
collector: properties.collector || "-",
city: properties.city || "-",
country: properties.country || "-",
source: properties.source || "-",
anomaly_type: properties.anomaly_type || "-",
status: properties.status || "-",
prefix: properties.prefix || "-",
origin_asn: properties.origin_asn ?? "-",
new_origin_asn: properties.new_origin_asn ?? "-",
as_path: Array.isArray(properties.as_path) ? properties.as_path : [],
collectors: Array.isArray(properties.collectors) ? properties.collectors : [],
collector_count: properties.collector_count ?? 1,
impacted_regions: Array.isArray(properties.impacted_regions)
? properties.impacted_regions
: [],
confidence: properties.confidence ?? "-",
summary: properties.summary || "-",
created_at: formatLocalDateTime(createdAt),
created_at_raw: createdAt,
id:
properties.id ||
`${properties.collector || "unknown"}-${latitude}-${longitude}`,
};
}
function clearMarkerArray(markers) {
while (markers.length > 0) {
const marker = markers.pop();
marker.material?.dispose();
bgpGroup.remove(marker);
}
}
function clearGroup(group) {
while (group.children.length > 0) {
const child = group.children[group.children.length - 1];
group.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
}
}
function createSpriteMaterial({ color, opacity }) {
return new THREE.SpriteMaterial({
map: getMarkerTexture(),
color,
transparent: true,
opacity,
depthWrite: false,
depthTest: true,
blending: THREE.AdditiveBlending,
});
}
function createOverlaySprite({ color, opacity, scale }) {
const sprite = new THREE.Sprite(createSpriteMaterial({ color, opacity }));
sprite.scale.setScalar(scale);
return sprite;
}
function createArcLine(start, end, color) {
const midpoint = start
.clone()
.add(end)
.multiplyScalar(0.5)
.normalize()
.multiplyScalar(CONFIG.earthRadius + BGP_CONFIG.eventHubAltitudeOffset * 0.8);
const curve = new THREE.QuadraticBezierCurve3(start, midpoint, end);
const points = curve.getPoints(32);
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.82,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
return new THREE.Line(geometry, material);
}
function createCollectorMarker(markerData) {
const sprite = new THREE.Sprite(
createSpriteMaterial({
color: BGP_CONFIG.collectorColor,
opacity: BGP_CONFIG.opacity.collector,
}),
);
const position = latLonToVector3(
markerData.displayLatitude,
markerData.displayLongitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset,
);
sprite.position.copy(position);
sprite.scale.setScalar(BGP_CONFIG.collectorScale);
sprite.renderOrder = 3;
sprite.visible = showBGP;
sprite.userData = {
type: "bgp_collector",
state: "normal",
baseScale: BGP_CONFIG.collectorScale,
pulseOffset: Math.random() * Math.PI * 2,
anomaly_count: 0,
...markerData,
};
collectorMarkers.push(sprite);
bgpGroup.add(sprite);
}
function createAnomalyMarker(markerData) {
const sprite = new THREE.Sprite(
createSpriteMaterial({
color: getSeverityColor(markerData.severity),
opacity: BGP_CONFIG.opacity.normal,
}),
);
const position = latLonToVector3(
markerData.latitude,
markerData.longitude,
CONFIG.earthRadius + BGP_CONFIG.altitudeOffset,
);
const baseScale = BGP_CONFIG.baseScale * getSeverityScale(markerData.severity);
sprite.position.copy(position);
sprite.scale.setScalar(baseScale);
sprite.renderOrder = 5;
sprite.visible = showBGP;
sprite.userData = {
type: "bgp",
state: "normal",
baseScale,
pulseOffset: Math.random() * Math.PI * 2,
...markerData,
};
anomalyMarkers.push(sprite);
bgpGroup.add(sprite);
}
function dedupeAnomalies(features) {
const latestByCollector = new Map();
features.forEach((feature) => {
const data = buildAnomalyFeatureData(feature);
if (!data) return;
anomalyCountByCollector.set(
data.collector,
(anomalyCountByCollector.get(data.collector) || 0) + 1,
);
const dedupeKey = `${data.collector}|${data.latitude.toFixed(4)}|${data.longitude.toFixed(4)}`;
const previous = latestByCollector.get(dedupeKey);
const currentTime = data.created_at_raw
? new Date(data.created_at_raw).getTime()
: 0;
const previousTime = previous?.created_at_raw
? new Date(previous.created_at_raw).getTime()
: 0;
if (!previous || currentTime >= previousTime) {
latestByCollector.set(dedupeKey, data);
}
});
return Array.from(latestByCollector.values())
.sort((a, b) => {
const timeA = a.created_at_raw ? new Date(a.created_at_raw).getTime() : 0;
const timeB = b.created_at_raw ? new Date(b.created_at_raw).getTime() : 0;
return timeB - timeA;
})
.slice(0, BGP_CONFIG.maxRenderedMarkers);
}
function applyCollectorCounts() {
collectorMarkers.forEach((marker) => {
marker.userData.anomaly_count =
anomalyCountByCollector.get(marker.userData.collector) || 0;
});
}
export async function loadBGPAnomalies(scene, earth) {
clearBGPData(earth);
const [collectorsResponse, anomaliesResponse] = await Promise.all([
fetch(PATHS.bgpCollectorsApi),
fetch(`${PATHS.bgpApi}?limit=${BGP_CONFIG.defaultFetchLimit}`),
]);
if (!collectorsResponse.ok) {
throw new Error(`BGP collectors HTTP ${collectorsResponse.status}`);
}
if (!anomaliesResponse.ok) {
throw new Error(`BGP anomalies HTTP ${anomaliesResponse.status}`);
}
const collectorsPayload = await collectorsResponse.json();
const anomaliesPayload = await anomaliesResponse.json();
const collectorFeatures = Array.isArray(collectorsPayload?.features)
? collectorsPayload.features
: [];
const anomalyFeatures = Array.isArray(anomaliesPayload?.features)
? anomaliesPayload.features
: [];
totalAnomalyCount = anomaliesPayload?.count ?? anomalyFeatures.length;
anomalyCountByCollector.clear();
spreadCollectorPositions(
collectorFeatures
.map(buildCollectorFeatureData)
.filter(Boolean),
).forEach(createCollectorMarker);
dedupeAnomalies(anomalyFeatures).forEach(createAnomalyMarker);
applyCollectorCounts();
if (!bgpGroup.parent) {
earth.add(bgpGroup);
}
if (!bgpOverlayGroup.parent) {
earth.add(bgpOverlayGroup);
}
bgpGroup.visible = showBGP;
bgpOverlayGroup.visible = showBGP;
if (scene && !scene.children.includes(earth)) {
scene.add(earth);
}
return {
totalCount: totalAnomalyCount,
renderedCount: anomalyMarkers.length,
collectorCount: collectorMarkers.length,
};
}
export function updateBGPVisualState(lockedObjectType, lockedObject) {
const now = performance.now();
const hasLockedLayer = Boolean(
lockedObject && ["cable", "satellite", "bgp", "bgp_collector"].includes(lockedObjectType),
);
collectorMarkers.forEach((marker) => {
const isLocked =
(lockedObjectType === "bgp_collector" || lockedObjectType === "bgp") &&
lockedObject?.userData?.collector === marker.userData.collector;
const isHovered =
marker.userData.state === "hover" || marker.userData.state === "linked";
const pulse =
0.5 +
0.5 *
Math.sin(
now * BGP_CONFIG.collectorPulseSpeed + marker.userData.pulseOffset,
);
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.collector;
if (isLocked) {
scale *= 1.1 + 0.14 * pulse;
opacity = BGP_CONFIG.opacity.collectorHover;
} else if (isHovered) {
scale *= 1.08;
opacity = BGP_CONFIG.opacity.collectorHover;
} else if (hasLockedLayer) {
scale *= BGP_CONFIG.dimmedScale;
opacity = BGP_CONFIG.opacity.dimmed;
} else {
scale *= 1 + 0.05 * pulse;
}
marker.scale.setScalar(scale);
marker.material.opacity = opacity;
marker.visible = showBGP;
});
anomalyMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "bgp" && lockedObject === marker;
const isLinkedCollectorLocked =
lockedObjectType === "bgp_collector" &&
lockedObject?.userData?.collector === marker.userData.collector;
const isOtherLocked = hasLockedLayer && !isLocked && !isLinkedCollectorLocked;
const isHovered = marker.userData.state === "hover";
const pulse =
0.5 +
0.5 * Math.sin(now * BGP_CONFIG.pulseSpeed + marker.userData.pulseOffset);
let scale = marker.userData.baseScale;
let opacity = BGP_CONFIG.opacity.normal;
if (isLocked || isLinkedCollectorLocked) {
scale *= 1 + BGP_CONFIG.lockedPulseAmplitude * pulse;
opacity =
BGP_CONFIG.opacity.lockedMin +
(BGP_CONFIG.opacity.lockedMax - BGP_CONFIG.opacity.lockedMin) * pulse;
} else if (isHovered) {
scale *= BGP_CONFIG.hoverScale;
opacity = BGP_CONFIG.opacity.hover;
} else if (isOtherLocked) {
scale *= BGP_CONFIG.dimmedScale;
opacity = BGP_CONFIG.opacity.dimmed;
} else {
scale *= 1 + BGP_CONFIG.normalPulseAmplitude * pulse;
opacity = BGP_CONFIG.opacity.normal;
}
marker.scale.setScalar(scale);
marker.material.opacity = opacity;
marker.visible = showBGP;
});
}
export function setBGPMarkerState(marker, state = "normal") {
if (!marker?.userData) return;
if (marker.userData.type !== "bgp" && marker.userData.type !== "bgp_collector") {
return;
}
marker.userData.state = state;
}
export function clearBGPSelection() {
collectorMarkers.forEach((marker) => {
marker.userData.state = "normal";
});
anomalyMarkers.forEach((marker) => {
marker.userData.state = "normal";
});
clearBGPEventOverlay();
}
export function clearBGPData(earth) {
clearMarkerArray(collectorMarkers);
clearMarkerArray(anomalyMarkers);
clearBGPEventOverlay();
anomalyCountByCollector.clear();
totalAnomalyCount = 0;
if (earth && bgpGroup.parent === earth) {
earth.remove(bgpGroup);
}
if (earth && bgpOverlayGroup.parent === earth) {
earth.remove(bgpOverlayGroup);
}
}
export function toggleBGP(show) {
showBGP = Boolean(show);
bgpGroup.visible = showBGP;
bgpOverlayGroup.visible = showBGP;
collectorMarkers.forEach((marker) => {
marker.visible = showBGP;
});
anomalyMarkers.forEach((marker) => {
marker.visible = showBGP;
});
}
export function getShowBGP() {
return showBGP;
}
export function getBGPMarkers() {
return [...anomalyMarkers, ...collectorMarkers];
}
export function getBGPAnomalyMarkers() {
return anomalyMarkers;
}
export function getBGPCollectorMarkers() {
return collectorMarkers;
}
export function getBGPCount() {
return totalAnomalyCount;
}
export function showBGPEventOverlay(marker, earth) {
if (!marker?.userData || marker.userData.type !== "bgp" || !earth) return;
clearBGPEventOverlay();
const impactedRegions =
Array.isArray(marker.userData.impacted_regions) &&
marker.userData.impacted_regions.length > 0
? marker.userData.impacted_regions
: [
{
collector: marker.userData.collector,
city: marker.userData.city,
country: marker.userData.country,
latitude: marker.userData.latitude,
longitude: marker.userData.longitude,
},
];
const validRegions = impactedRegions.filter(
(region) =>
typeof region?.latitude === "number" &&
typeof region?.longitude === "number",
);
if (validRegions.length === 0) return;
const averageLatitude =
validRegions.reduce((sum, region) => sum + region.latitude, 0) /
validRegions.length;
const averageLongitude =
validRegions.reduce((sum, region) => sum + region.longitude, 0) /
validRegions.length;
const hubPosition = latLonToVector3(
averageLatitude,
averageLongitude,
CONFIG.earthRadius + BGP_CONFIG.eventHubAltitudeOffset,
);
const hub = createOverlaySprite({
color: BGP_CONFIG.eventHubColor,
opacity: 0.95,
scale: BGP_CONFIG.eventHubScale,
});
hub.position.copy(hubPosition);
hub.renderOrder = 6;
bgpOverlayGroup.add(hub);
const overlayItems = [hub];
validRegions.forEach((region) => {
const regionPosition = latLonToVector3(
region.latitude,
region.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset + 0.3,
);
const link = createArcLine(regionPosition, hubPosition, BGP_CONFIG.linkColor);
link.renderOrder = 4;
bgpOverlayGroup.add(link);
overlayItems.push(link);
const halo = createOverlaySprite({
color: BGP_CONFIG.regionColor,
opacity: 0.24,
scale: BGP_CONFIG.regionScale,
});
halo.position.copy(
latLonToVector3(
region.latitude,
region.longitude,
CONFIG.earthRadius + BGP_CONFIG.collectorAltitudeOffset - 0.1,
),
);
halo.renderOrder = 2;
bgpOverlayGroup.add(halo);
overlayItems.push(halo);
});
activeEventOverlay = overlayItems;
bgpOverlayGroup.visible = showBGP;
}
export function clearBGPEventOverlay() {
activeEventOverlay = null;
clearGroup(bgpOverlayGroup);
}
export function getBGPLegendItems() {
return [
{ color: "#6db7ff", label: "观测站" },
{ color: "#8af5ff", label: "事件连线 / 枢纽" },
{ color: "#2dd4bf", label: "影响区域" },
{ color: "#ff4d4f", label: "严重异常" },
{ color: "#ff9f43", label: "高危异常" },
{ color: "#ffd166", label: "中危异常" },
{ color: "#4dabf7", label: "低危异常" },
];
}

View File

@@ -1,339 +1,460 @@
// cables.js - Cable loading and rendering module
import * as THREE from 'three';
import * as THREE from "three";
import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from './constants.js';
import { latLonToVector3 } from './utils.js';
import { updateEarthStats, showStatusMessage } from './ui.js';
import { showInfoCard } from './info-card.js';
import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import { updateEarthStats, showStatusMessage } from "./ui.js";
import { showInfoCard } from "./info-card.js";
import { setLegendItems, setLegendMode } from "./legend.js";
export let cableLines = [];
export let landingPoints = [];
export let lockedCable = null;
let cableIdMap = new Map();
let cableStates = new Map();
let cablesVisible = true;
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
if (material.map) {
material.map.dispose();
}
material.dispose();
}
function disposeObject(object, parent) {
if (!object) return;
const owner = parent || object.parent;
if (owner) {
owner.remove(object);
}
if (object.geometry) {
object.geometry.dispose();
}
if (object.material) {
disposeMaterial(object.material);
}
}
function getCableColor(properties) {
if (properties.color) {
if (typeof properties.color === 'string' && properties.color.startsWith('#')) {
if (
typeof properties.color === "string" &&
properties.color.startsWith("#")
) {
return parseInt(properties.color.substring(1), 16);
} else if (typeof properties.color === 'number') {
}
if (typeof properties.color === "number") {
return properties.color;
}
}
const cableName = properties.Name || properties.cableName || properties.shortname || '';
if (cableName.includes('Americas II')) {
return CABLE_COLORS['Americas II'];
} else if (cableName.includes('AU Aleutian A')) {
return CABLE_COLORS['AU Aleutian A'];
} else if (cableName.includes('AU Aleutian B')) {
return CABLE_COLORS['AU Aleutian B'];
const cableName =
properties.Name ||
properties.name ||
properties.cableName ||
properties.shortname ||
"";
if (cableName.includes("Americas II")) {
return CABLE_COLORS["Americas II"];
}
if (cableName.includes("AU Aleutian A")) {
return CABLE_COLORS["AU Aleutian A"];
}
if (cableName.includes("AU Aleutian B")) {
return CABLE_COLORS["AU Aleutian B"];
}
return CABLE_COLORS.default;
}
function createCableLine(points, color, properties, earthObj) {
function createCableLine(points, color, properties) {
if (points.length < 2) return null;
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
const lineMaterial = new THREE.LineBasicMaterial({
color: color,
lineGeometry.computeBoundingSphere();
const lineMaterial = new THREE.LineBasicMaterial({
color,
linewidth: 1,
transparent: true,
opacity: 1.0,
depthTest: true,
depthWrite: true
depthWrite: true,
});
const cableLine = new THREE.Line(lineGeometry, lineMaterial);
const cableId = properties.cable_id || properties.id || properties.Name || Math.random().toString(36);
const cableId =
properties.cable_id ||
properties.id ||
properties.Name ||
properties.name ||
Math.random().toString(36);
cableLine.userData = {
type: 'cable',
cableId: cableId,
name: properties.Name || properties.cableName || 'Unknown',
owner: properties.owner || properties.owners || '-',
status: properties.status || '-',
length: properties.length || '-',
coords: '-',
rfs: properties.rfs || '-',
originalColor: color
type: "cable",
cableId,
name:
properties.Name ||
properties.name ||
properties.cableName ||
properties.shortname ||
"Unknown",
owner: properties.owner || properties.owners || "-",
status: properties.status || "-",
length: properties.length || "-",
coords: "-",
rfs: properties.rfs || "-",
originalColor: color,
localCenter:
lineGeometry.boundingSphere?.center?.clone() || new THREE.Vector3(),
};
cableLine.renderOrder = 1;
if (!cableIdMap.has(cableId)) {
cableIdMap.set(cableId, []);
}
cableIdMap.get(cableId).push(cableLine);
return cableLine;
}
function calculateGreatCirclePoints(lat1, lon1, lat2, lon2, radius, segments = 50) {
function calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
radius,
segments = 50,
) {
const points = [];
const phi1 = lat1 * Math.PI / 180;
const lambda1 = lon1 * Math.PI / 180;
const phi2 = lat2 * Math.PI / 180;
const lambda2 = lon2 * Math.PI / 180;
const dLambda = Math.min(Math.abs(lambda2 - lambda1), 2 * Math.PI - Math.abs(lambda2 - lambda1));
const cosDelta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
const phi1 = (lat1 * Math.PI) / 180;
const lambda1 = (lon1 * Math.PI) / 180;
const phi2 = (lat2 * Math.PI) / 180;
const lambda2 = (lon2 * Math.PI) / 180;
const dLambda = Math.min(
Math.abs(lambda2 - lambda1),
2 * Math.PI - Math.abs(lambda2 - lambda1),
);
const cosDelta =
Math.sin(phi1) * Math.sin(phi2) +
Math.cos(phi1) * Math.cos(phi2) * Math.cos(dLambda);
let delta = Math.acos(Math.max(-1, Math.min(1, cosDelta)));
if (delta < 0.01) {
const p1 = latLonToVector3(lat1, lon1, radius);
const p2 = latLonToVector3(lat2, lon2, radius);
return [p1, p2];
}
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const sinDelta = Math.sin(delta);
const A = Math.sin((1 - t) * delta) / sinDelta;
const B = Math.sin(t * delta) / sinDelta;
const x1 = Math.cos(phi1) * Math.cos(lambda1);
const y1 = Math.cos(phi1) * Math.sin(lambda1);
const z1 = Math.sin(phi1);
const x2 = Math.cos(phi2) * Math.cos(lambda2);
const y2 = Math.cos(phi2) * Math.sin(lambda2);
const z2 = Math.sin(phi2);
let x = A * x1 + B * x2;
let y = A * y1 + B * y2;
let z = A * z1 + B * z2;
const norm = Math.sqrt(x*x + y*y + z*z);
x = x / norm * radius;
y = y / norm * radius;
z = z / norm * radius;
const lat = Math.asin(z / radius) * 180 / Math.PI;
let lon = Math.atan2(y, x) * 180 / Math.PI;
const norm = Math.sqrt(x * x + y * y + z * z);
x = (x / norm) * radius;
y = (y / norm) * radius;
z = (z / norm) * radius;
const lat = (Math.asin(z / radius) * 180) / Math.PI;
let lon = (Math.atan2(y, x) * 180) / Math.PI;
if (lon > 180) lon -= 360;
if (lon < -180) lon += 360;
const point = latLonToVector3(lat, lon, radius);
points.push(point);
points.push(latLonToVector3(lat, lon, radius));
}
return points;
}
export function clearCableLines(earthObj = null) {
cableLines.forEach((line) => disposeObject(line, earthObj));
cableLines = [];
cableIdMap = new Map();
cableStates.clear();
}
export function clearLandingPoints(earthObj = null) {
landingPoints.forEach((point) => disposeObject(point, earthObj));
landingPoints = [];
}
export function clearCableData(earthObj = null) {
clearCableSelection();
clearCableLines(earthObj);
clearLandingPoints(earthObj);
}
export async function loadGeoJSONFromPath(scene, earthObj) {
try {
console.log('正在加载电缆数据...');
showStatusMessage('正在加载电缆数据...', 'warning');
const response = await fetch(PATHS.cablesApi);
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
const data = await response.json();
cableLines.forEach(line => earthObj.remove(line));
cableLines = [];
if (!data.features || !Array.isArray(data.features)) {
throw new Error('无效的GeoJSON格式');
}
const cableCount = data.features.length;
document.getElementById('cable-count').textContent = cableCount + '个';
const inServiceCount = data.features.filter(
feature => feature.properties && feature.properties.status === 'In Service'
).length;
const statusEl = document.getElementById('cable-status-summary');
if (statusEl) {
statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
}
for (const feature of data.features) {
const geometry = feature.geometry;
const properties = feature.properties || {};
if (!geometry || !geometry.coordinates) continue;
const color = getCableColor(properties);
console.log('电缆 properties:', JSON.stringify(properties));
if (geometry.type === 'MultiLineString') {
for (const lineCoords of geometry.coordinates) {
if (!lineCoords || lineCoords.length < 2) continue;
const points = [];
for (let i = 0; i < lineCoords.length - 1; i++) {
const lon1 = lineCoords[i][0];
const lat1 = lineCoords[i][1];
const lon2 = lineCoords[i + 1][0];
const lat2 = lineCoords[i + 1][1];
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
if (i === 0) {
points.push(...segment);
} else {
points.push(...segment.slice(1));
}
}
if (points.length >= 2) {
const line = createCableLine(points, color, properties, earthObj);
if (line) {
cableLines.push(line);
earthObj.add(line);
console.log('添加线缆成功');
}
}
}
} else if (geometry.type === 'LineString') {
const allCoords = geometry.coordinates;
console.log("正在加载电缆数据...");
showStatusMessage("正在加载电缆数据...", "warning");
const response = await fetch(PATHS.cablesApi);
if (!response.ok) {
throw new Error(`电缆接口返回 HTTP ${response.status}`);
}
const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
throw new Error("无效的电缆 GeoJSON 格式");
}
clearCableLines(earthObj);
for (const feature of data.features) {
const geometry = feature.geometry;
const properties = feature.properties || {};
if (!geometry || !geometry.coordinates) continue;
const color = getCableColor(properties);
if (geometry.type === "MultiLineString") {
for (const lineCoords of geometry.coordinates) {
if (!lineCoords || lineCoords.length < 2) continue;
const points = [];
for (let i = 0; i < allCoords.length - 1; i++) {
const lon1 = allCoords[i][0];
const lat1 = allCoords[i][1];
const lon2 = allCoords[i + 1][0];
const lat2 = allCoords[i + 1][1];
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
if (i === 0) {
points.push(...segment);
} else {
points.push(...segment.slice(1));
}
for (let i = 0; i < lineCoords.length - 1; i++) {
const lon1 = lineCoords[i][0];
const lat1 = lineCoords[i][1];
const lon2 = lineCoords[i + 1][0];
const lat2 = lineCoords[i + 1][1];
const segment = calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
CONFIG.earthRadius + 0.2,
50,
);
points.push(...(i === 0 ? segment : segment.slice(1)));
}
if (points.length >= 2) {
const line = createCableLine(points, color, properties, earthObj);
if (line) {
cableLines.push(line);
earthObj.add(line);
}
const line = createCableLine(points, color, properties);
if (line) {
cableLines.push(line);
earthObj.add(line);
}
}
} else if (geometry.type === "LineString") {
const points = [];
for (let i = 0; i < geometry.coordinates.length - 1; i++) {
const lon1 = geometry.coordinates[i][0];
const lat1 = geometry.coordinates[i][1];
const lon2 = geometry.coordinates[i + 1][0];
const lat2 = geometry.coordinates[i + 1][1];
const segment = calculateGreatCirclePoints(
lat1,
lon1,
lat2,
lon2,
CONFIG.earthRadius + 0.2,
50,
);
points.push(...(i === 0 ? segment : segment.slice(1)));
}
const line = createCableLine(points, color, properties);
if (line) {
cableLines.push(line);
earthObj.add(line);
}
}
updateEarthStats({
cableCount: cableLines.length,
landingPointCount: landingPoints.length,
terrainOn: false,
textureQuality: '8K 卫星图'
});
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, 'success');
document.getElementById('loading').style.display = 'none';
} catch (error) {
console.error('加载电缆数据失败:', error);
showStatusMessage('加载电缆数据失败: ' + error.message, 'error');
}
const cableCount = data.features.length;
const inServiceCount = data.features.filter(
(feature) =>
feature.properties && feature.properties.status === "In Service",
).length;
const cableCountEl = document.getElementById("cable-count");
const statusEl = document.getElementById("cable-status-summary");
if (cableCountEl) cableCountEl.textContent = cableCount + "个";
if (statusEl) statusEl.textContent = `${inServiceCount}/${cableCount} 运行中`;
updateEarthStats({
cableCount: cableLines.length,
landingPointCount: landingPoints.length,
terrainOn: false,
textureQuality: "8K 卫星图",
});
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
return cableLines.length;
}
export async function loadLandingPoints(scene, earthObj) {
console.log("正在加载登陆点数据...");
const response = await fetch(PATHS.landingPointsApi);
if (!response.ok) {
throw new Error(`登陆点接口返回 HTTP ${response.status}`);
}
const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
throw new Error("无效的登陆点 GeoJSON 格式");
}
clearLandingPoints(earthObj);
const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16);
let validCount = 0;
try {
console.log('正在加载登陆点数据...');
const response = await fetch(PATHS.landingPointsApi);
if (!response.ok) {
console.error('HTTP错误:', response.status);
return;
}
const data = await response.json();
if (!data.features || !Array.isArray(data.features)) {
console.error('无效的GeoJSON格式');
return;
}
landingPoints = [];
let validCount = 0;
const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16);
const sphereMaterial = new THREE.MeshStandardMaterial({
color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5
});
for (const feature of data.features) {
if (!feature.geometry || !feature.geometry.coordinates) continue;
const [lon, lat] = feature.geometry.coordinates;
const properties = feature.properties || {};
if (typeof lon !== 'number' || typeof lat !== 'number' ||
isNaN(lon) || isNaN(lat) ||
Math.abs(lat) > 90 || Math.abs(lon) > 180) {
if (
typeof lon !== "number" ||
typeof lat !== "number" ||
Number.isNaN(lon) ||
Number.isNaN(lat) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
) {
continue;
}
const position = latLonToVector3(lat, lon, 100.1);
if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) {
const position = latLonToVector3(lat, lon, CONFIG.earthRadius + 0.1);
if (
Number.isNaN(position.x) ||
Number.isNaN(position.y) ||
Number.isNaN(position.z)
) {
continue;
}
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial.clone());
const sphere = new THREE.Mesh(
sphereGeometry.clone(),
new THREE.MeshStandardMaterial({
color: 0xffaa00,
emissive: 0x442200,
emissiveIntensity: 0.5,
transparent: true,
opacity: 1,
}),
);
sphere.position.copy(position);
sphere.userData = {
type: 'landingPoint',
name: properties.name || '未知登陆站',
type: "landingPoint",
name: properties.name || "未知登陆站",
cableNames: properties.cable_names || [],
country: properties.country || '未知国家',
status: properties.status || 'Unknown'
country: properties.country || "未知国家",
status: properties.status || "Unknown",
};
earthObj.add(sphere);
landingPoints.push(sphere);
validCount++;
}
console.log(`成功创建 ${validCount} 个登陆点标记`);
showStatusMessage(`成功加载 ${validCount} 个登陆点`, 'success');
const lpCountEl = document.getElementById('landing-point-count');
if (lpCountEl) {
lpCountEl.textContent = validCount + '个';
}
} catch (error) {
console.error('加载登陆点数据失败:', error);
} finally {
sphereGeometry.dispose();
}
const landingPointCountEl = document.getElementById("landing-point-count");
if (landingPointCountEl) {
landingPointCountEl.textContent = validCount + "个";
}
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
return validCount;
}
export function handleCableClick(cable) {
lockedCable = cable;
setLegendItems("cables", getCableLegendItems());
const data = cable.userData;
showInfoCard('cable', {
setLegendMode("cables");
showInfoCard("cable", {
name: data.name,
owner: data.owner,
status: data.status,
length: data.length,
coords: data.coords,
rfs: data.rfs
rfs: data.rfs,
});
showStatusMessage(`已锁定: ${data.name}`, 'info');
showStatusMessage(`已锁定: ${data.name}`, "info");
}
export function clearCableSelection() {
lockedCable = null;
setLegendItems("cables", getCableLegendItems());
}
export function getCableLines() {
return cableLines;
}
export function getCableLegendItems() {
const legendMap = new Map();
cableLines.forEach((cable) => {
const color = cable.userData?.originalColor;
const label = cable.userData?.name || "未知线缆";
if (typeof color === "number" && !legendMap.has(label)) {
legendMap.set(label, {
label,
color: `#${color.toString(16).padStart(6, "0")}`,
});
}
});
if (legendMap.size === 0) {
return [{ label: "其他电缆", color: "#ffff44" }];
}
const items = Array.from(legendMap.values()).sort((a, b) =>
a.label.localeCompare(b.label, "zh-CN"),
);
const selectedName = lockedCable?.userData?.name;
if (!selectedName) {
return items;
}
const selectedIndex = items.findIndex((item) => item.label === selectedName);
if (selectedIndex <= 0) {
return items;
}
const [selectedItem] = items.splice(selectedIndex, 1);
items.unshift(selectedItem);
return items;
}
export function getCablesById(cableId) {
return cableIdMap.get(cableId) || [];
}
@@ -342,8 +463,6 @@ export function getLandingPoints() {
return landingPoints;
}
const cableStates = new Map();
export function getCableState(cableId) {
return cableStates.get(cableId) || CABLE_STATE.NORMAL;
}
@@ -365,7 +484,9 @@ export function getCableStateInfo() {
}
export function getLandingPointsByCableName(cableName) {
return landingPoints.filter(lp => lp.userData.cableNames?.includes(cableName));
return landingPoints.filter((lp) =>
lp.userData.cableNames?.includes(cableName),
);
}
export function getAllLandingPoints() {
@@ -375,10 +496,11 @@ export function getAllLandingPoints() {
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
const brightness = 0.3;
landingPoints.forEach(lp => {
const isRelated = !dimAll && lp.userData.cableNames?.includes(lockedCableName);
landingPoints.forEach((lp) => {
const isRelated =
!dimAll && lp.userData.cableNames?.includes(lockedCableName);
if (isRelated) {
lp.material.color.setHex(0xffaa00);
lp.material.emissive.setHex(0x442200);
@@ -388,8 +510,7 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
} else {
const r = 255 * brightness;
const g = 170 * brightness;
const b = 0 * brightness;
lp.material.color.setRGB(r / 255, g / 255, b / 255);
lp.material.color.setRGB(r / 255, g / 255, 0);
lp.material.emissive.setHex(0x000000);
lp.material.emissiveIntensity = 0;
lp.material.opacity = 0.3;
@@ -399,7 +520,7 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
}
export function resetLandingPointVisualState() {
landingPoints.forEach(lp => {
landingPoints.forEach((lp) => {
lp.material.color.setHex(0xffaa00);
lp.material.emissive.setHex(0x442200);
lp.material.emissiveIntensity = 0.5;
@@ -410,10 +531,10 @@ export function resetLandingPointVisualState() {
export function toggleCables(show) {
cablesVisible = show;
cableLines.forEach(cable => {
cableLines.forEach((cable) => {
cable.visible = cablesVisible;
});
landingPoints.forEach(lp => {
landingPoints.forEach((lp) => {
lp.visible = cablesVisible;
});
}

View File

@@ -26,6 +26,8 @@ export const EARTH_CONFIG = {
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
geoJSON: './geo.json',
landingPointsStatic: './landing-point-geo.geojson',
};
@@ -54,7 +56,7 @@ export const CABLE_STATE = {
};
export const SATELLITE_CONFIG = {
maxCount: 5000,
maxCount: -1,
trailLength: 10,
dotSize: 4,
ringSize: 0.07,
@@ -69,6 +71,49 @@ export const SATELLITE_CONFIG = {
dotOpacityMax: 1.0
};
export const BGP_CONFIG = {
defaultFetchLimit: 200,
maxRenderedMarkers: 200,
altitudeOffset: 2.1,
collectorAltitudeOffset: 1.6,
baseScale: 6.2,
collectorScale: 7.4,
hoverScale: 1.16,
dimmedScale: 0.92,
pulseSpeed: 0.0045,
collectorPulseSpeed: 0.0024,
eventHubAltitudeOffset: 7.2,
eventHubScale: 4.8,
regionScale: 11.5,
normalPulseAmplitude: 0.08,
lockedPulseAmplitude: 0.28,
opacity: {
normal: 0.78,
hover: 1.0,
dimmed: 0.24,
collector: 0.82,
collectorHover: 1.0,
lockedMin: 0.65,
lockedMax: 1.0
},
severityColors: {
critical: 0xff4d4f,
high: 0xff9f43,
medium: 0xffd166,
low: 0x4dabf7
},
severityScales: {
critical: 1.18,
high: 1.08,
medium: 1.0,
low: 0.94
},
collectorColor: 0x6db7ff,
eventHubColor: 0x8af5ff,
linkColor: 0x54d2ff,
regionColor: 0x2dd4bf
};
export const PREDICTED_ORBIT_CONFIG = {
sampleInterval: 10,
opacity: 0.8

View File

@@ -1,25 +1,55 @@
// controls.js - Zoom, rotate and toggle controls
import { CONFIG, EARTH_CONFIG } from './constants.js';
import { updateZoomDisplay, showStatusMessage } from './ui.js';
import { toggleTerrain } from './earth.js';
import { reloadData, clearLockedObject } from './main.js';
import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js';
import { toggleCables, getShowCables } from './cables.js';
import { CONFIG, EARTH_CONFIG } from "./constants.js";
import { updateZoomDisplay, showStatusMessage } from "./ui.js";
import { toggleTerrain } from "./earth.js";
import {
reloadData,
clearLockedObject,
setCablesEnabled,
setSatellitesEnabled,
} from "./main.js";
import {
toggleTrails,
getShowSatellites,
getSatelliteCount,
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
export let autoRotate = true;
export let zoomLevel = 1.0;
export let showTerrain = false;
export let isDragging = false;
export let layoutExpanded = false;
let earthObj = null;
let listeners = [];
let cleanupFns = [];
function bindListener(element, eventName, handler, options) {
if (!element) return;
element.addEventListener(eventName, handler, options);
listeners.push(() =>
element.removeEventListener(eventName, handler, options),
);
}
function resetCleanup() {
cleanupFns.forEach((cleanup) => cleanup());
cleanupFns = [];
listeners.forEach((cleanup) => cleanup());
listeners = [];
}
export function setupControls(camera, renderer, scene, earth) {
resetCleanup();
earthObj = earth;
setupZoomControls(camera);
setupWheelZoom(camera, renderer);
setupRotateControls(camera, earth);
setupTerrainControls();
setupLiquidGlassInteractions();
}
function setupZoomControls(camera) {
@@ -29,39 +59,40 @@ function setupZoomControls(camera) {
const HOLD_THRESHOLD = 150;
const LONG_PRESS_TICK = 50;
const CLICK_STEP = 10;
const MIN_PERCENT = CONFIG.minZoom * 100;
const MAX_PERCENT = CONFIG.maxZoom * 100;
function doZoomStep(direction) {
let currentPercent = Math.round(zoomLevel * 100);
let newPercent = direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
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);
}
function doContinuousZoom(direction) {
let currentPercent = Math.round(zoomLevel * 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);
}
function startContinuousZoom(direction) {
doContinuousZoom(direction);
zoomInterval = setInterval(() => {
zoomInterval = window.setInterval(() => {
doContinuousZoom(direction);
}, LONG_PRESS_TICK);
}
function stopZoom() {
if (zoomInterval) {
clearInterval(zoomInterval);
@@ -72,15 +103,15 @@ function setupZoomControls(camera) {
holdTimeout = null;
}
}
function handleMouseDown(direction) {
startTime = Date.now();
stopZoom();
holdTimeout = setTimeout(() => {
holdTimeout = window.setTimeout(() => {
startContinuousZoom(direction);
}, HOLD_THRESHOLD);
}
function handleMouseUp(direction) {
const heldTime = Date.now() - startTime;
stopZoom();
@@ -88,48 +119,72 @@ function setupZoomControls(camera) {
doZoomStep(direction);
}
}
document.getElementById('zoom-in').addEventListener('mousedown', () => handleMouseDown(1));
document.getElementById('zoom-in').addEventListener('mouseup', () => handleMouseUp(1));
document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom);
document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(1); });
document.getElementById('zoom-in').addEventListener('touchend', () => handleMouseUp(1));
document.getElementById('zoom-out').addEventListener('mousedown', () => handleMouseDown(-1));
document.getElementById('zoom-out').addEventListener('mouseup', () => handleMouseUp(-1));
document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom);
document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(-1); });
document.getElementById('zoom-out').addEventListener('touchend', () => handleMouseUp(-1));
document.getElementById('zoom-value').addEventListener('click', function() {
cleanupFns.push(stopZoom);
const zoomIn = document.getElementById("zoom-in");
const zoomOut = document.getElementById("zoom-out");
const zoomValue = document.getElementById("zoom-value");
bindListener(zoomIn, "mousedown", () => handleMouseDown(1));
bindListener(zoomIn, "mouseup", () => handleMouseUp(1));
bindListener(zoomIn, "mouseleave", stopZoom);
bindListener(zoomIn, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(1);
});
bindListener(zoomIn, "touchend", () => handleMouseUp(1));
bindListener(zoomOut, "mousedown", () => handleMouseDown(-1));
bindListener(zoomOut, "mouseup", () => handleMouseUp(-1));
bindListener(zoomOut, "mouseleave", stopZoom);
bindListener(zoomOut, "touchstart", (e) => {
e.preventDefault();
handleMouseDown(-1);
});
bindListener(zoomOut, "touchend", () => handleMouseUp(-1));
bindListener(zoomValue, "click", () => {
const startZoomVal = zoomLevel;
const targetZoom = 1.0;
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
animateValue(0, 1, 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));
}, () => {
zoomLevel = 1.0;
showStatusMessage('缩放已重置到100%', 'info');
});
animateValue(
0,
1,
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));
},
() => {
zoomLevel = 1.0;
showStatusMessage("缩放已重置到100%", "info");
},
);
});
}
function setupWheelZoom(camera, renderer) {
renderer.domElement.addEventListener('wheel', (e) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
} else {
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
}, { passive: false });
bindListener(
renderer?.domElement,
"wheel",
(e) => {
e.preventDefault();
if (e.deltaY < 0) {
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
} else {
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
}
applyZoom(camera);
},
{ passive: false },
);
}
function applyZoom(camera) {
@@ -140,136 +195,301 @@ function applyZoom(camera) {
function animateValue(start, end, duration, onUpdate, onComplete) {
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 3);
const current = start + (end - start) * easeProgress;
onUpdate(current);
if (progress < 1) {
requestAnimationFrame(update);
} else if (onComplete) {
onComplete();
}
}
requestAnimationFrame(update);
}
export function resetView(camera) {
if (!earthObj) return;
function animateToView(targetLat, targetLon, targetRotLon) {
const latRot = targetLat * Math.PI / 180;
const targetRotX = EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
const targetRotY = -(targetRotLon * Math.PI / 180);
const latRot = (targetLat * Math.PI) / 180;
const targetRotX =
EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
const targetRotY = -((targetRotLon * Math.PI) / 180);
const startRotX = earthObj.rotation.x;
const startRotY = earthObj.rotation.y;
const startZoom = zoomLevel;
const targetZoom = 1.0;
animateValue(0, 1, 800, (progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
}, () => {
zoomLevel = 1.0;
showStatusMessage('视角已重置', 'info');
});
animateValue(
0,
1,
800,
(progress) => {
const ease = 1 - Math.pow(1 - progress, 3);
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
},
() => {
zoomLevel = 1.0;
showStatusMessage("视角已重置", "info");
},
);
}
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => animateToView(pos.coords.latitude, pos.coords.longitude, -pos.coords.longitude),
() => animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon),
{ timeout: 5000, enableHighAccuracy: false }
(pos) =>
animateToView(
pos.coords.latitude,
pos.coords.longitude,
-pos.coords.longitude,
),
() =>
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
),
{ timeout: 5000, enableHighAccuracy: false },
);
} else {
animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon);
}
if (typeof window.clearLockedCable === 'function') {
window.clearLockedCable();
animateToView(
EARTH_CONFIG.chinaLat,
EARTH_CONFIG.chinaLon,
EARTH_CONFIG.chinaRotLon,
);
}
clearLockedObject();
}
function setupRotateControls(camera, earth) {
const rotateBtn = document.getElementById('rotate-toggle');
rotateBtn.addEventListener('click', function() {
function setupRotateControls(camera) {
const rotateBtn = document.getElementById("rotate-toggle");
const resetViewBtn = document.getElementById("reset-view");
bindListener(rotateBtn, "click", () => {
const isRotating = toggleAutoRotate();
showStatusMessage(isRotating ? '自动旋转已开启' : '自动旋转已暂停', 'info');
showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
});
updateRotateUI();
document.getElementById('reset-view').addEventListener('click', function() {
bindListener(resetViewBtn, "click", () => {
resetView(camera);
});
}
function setupTerrainControls() {
document.getElementById('toggle-terrain').addEventListener('click', function() {
const container = document.getElementById("container");
const searchBtn = document.getElementById("search-action");
const infoGroup = document.getElementById("info-control-group");
const infoTrigger = document.getElementById("info-trigger");
const terrainBtn = document.getElementById("toggle-terrain");
const satellitesBtn = document.getElementById("toggle-satellites");
const bgpBtn = document.getElementById("toggle-bgp");
const trailsBtn = document.getElementById("toggle-trails");
const cablesBtn = document.getElementById("toggle-cables");
const layoutBtn = document.getElementById("layout-toggle");
const reloadBtn = document.getElementById("reload-data");
const zoomGroup = document.getElementById("zoom-control-group");
const zoomTrigger = document.getElementById("zoom-trigger");
if (trailsBtn) {
trailsBtn.classList.add("active");
const tooltip = trailsBtn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = "隐藏轨迹";
}
bindListener(searchBtn, "click", () => {
showStatusMessage("搜索功能待开发", "info");
});
bindListener(terrainBtn, "click", function () {
showTerrain = !showTerrain;
toggleTerrain(showTerrain);
this.classList.toggle('active', showTerrain);
this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
this.classList.toggle("active", showTerrain);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showTerrain ? "隐藏地形" : "显示地形";
const terrainStatus = document.getElementById("terrain-status");
if (terrainStatus)
terrainStatus.textContent = showTerrain ? "开启" : "关闭";
showStatusMessage(showTerrain ? "地形已显示" : "地形已隐藏", "info");
});
document.getElementById('toggle-satellites').addEventListener('click', function() {
bindListener(satellitesBtn, "click", async function () {
const showSats = !getShowSatellites();
if (!showSats) {
clearLockedObject();
}
toggleSatellites(showSats);
this.classList.toggle('active', showSats);
this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星';
document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗';
showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info');
try {
await setSatellitesEnabled(showSats);
if (!showSats) {
showStatusMessage("卫星已隐藏", "info");
} else {
const satelliteCountEl = document.getElementById("satellite-count");
if (satelliteCountEl) {
satelliteCountEl.textContent = `${getSatelliteCount()}`;
}
}
} catch (error) {
console.error("切换卫星显示失败:", error);
}
});
document.getElementById('toggle-trails').addEventListener('click', function() {
const isActive = this.classList.contains('active');
const showTrails = !isActive;
toggleTrails(showTrails);
this.classList.toggle('active', showTrails);
this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹';
showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info');
});
document.getElementById('toggle-cables').addEventListener('click', function() {
const showCables = !getShowCables();
if (!showCables) {
bindListener(bgpBtn, "click", function () {
const showNextBGP = !getShowBGP();
if (!showNextBGP) {
clearLockedObject();
}
toggleCables(showCables);
this.classList.toggle('active', showCables);
this.querySelector('.tooltip').textContent = showCables ? '隐藏线缆' : '显示线缆';
showStatusMessage(showCables ? '线缆已显示' : '线缆已隐藏', 'info');
toggleBGP(showNextBGP);
this.classList.toggle("active", showNextBGP);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = showNextBGP ? "隐藏BGP观测" : "显示BGP观测";
const bgpCountEl = document.getElementById("bgp-anomaly-count");
if (bgpCountEl) {
bgpCountEl.textContent = `${getBGPCount()}`;
}
showStatusMessage(showNextBGP ? "BGP观测已显示" : "BGP观测已隐藏", "info");
});
document.getElementById('reload-data').addEventListener('click', async () => {
bindListener(trailsBtn, "click", function () {
const isActive = this.classList.contains("active");
const nextShowTrails = !isActive;
toggleTrails(nextShowTrails);
this.classList.toggle("active", nextShowTrails);
const tooltip = this.querySelector(".tooltip");
if (tooltip) tooltip.textContent = nextShowTrails ? "隐藏轨迹" : "显示轨迹";
showStatusMessage(nextShowTrails ? "轨迹已显示" : "轨迹已隐藏", "info");
});
bindListener(cablesBtn, "click", async function () {
const showNextCables = !getShowCables();
if (!showNextCables) {
clearLockedObject();
}
try {
await setCablesEnabled(showNextCables);
} catch (error) {
console.error("切换线缆显示失败:", error);
}
});
bindListener(reloadBtn, "click", async () => {
await reloadData();
showStatusMessage('数据已重新加载', 'success');
});
const toolbarToggle = document.getElementById('toolbar-toggle');
const toolbar = document.getElementById('control-toolbar');
if (toolbarToggle && toolbar) {
toolbarToggle.addEventListener('click', () => {
toolbar.classList.toggle('collapsed');
bindListener(zoomTrigger, "click", (event) => {
event.stopPropagation();
infoGroup?.classList.remove("open");
zoomGroup?.classList.toggle("open");
});
bindListener(zoomGroup, "click", (event) => {
event.stopPropagation();
});
bindListener(infoTrigger, "click", (event) => {
event.stopPropagation();
zoomGroup?.classList.remove("open");
infoGroup?.classList.toggle("open");
});
bindListener(infoGroup, "click", (event) => {
event.stopPropagation();
});
bindListener(document, "click", (event) => {
if (zoomGroup?.classList.contains("open")) {
if (!zoomGroup.contains(event.target)) {
zoomGroup.classList.remove("open");
}
}
if (infoGroup?.classList.contains("open")) {
if (!infoGroup.contains(event.target)) {
infoGroup.classList.remove("open");
}
}
});
bindListener(layoutBtn, "click", () => {
const expanded = toggleLayoutExpanded(container);
showStatusMessage(expanded ? "布局已最大化" : "布局已恢复", "info");
});
updateLayoutUI(container);
}
function setupLiquidGlassInteractions() {
const surfaces = document.querySelectorAll(".liquid-glass-surface");
const resetSurface = (surface) => {
surface.style.setProperty("--elastic-x", "0px");
surface.style.setProperty("--elastic-y", "0px");
surface.style.setProperty("--tilt-x", "0deg");
surface.style.setProperty("--tilt-y", "0deg");
surface.style.setProperty("--glow-x", "50%");
surface.style.setProperty("--glow-y", "22%");
surface.style.setProperty("--glow-opacity", "0.24");
surface.classList.remove("is-pressed");
};
surfaces.forEach((surface) => {
resetSurface(surface);
bindListener(surface, "pointermove", (event) => {
const rect = surface.getBoundingClientRect();
const px = (event.clientX - rect.left) / rect.width;
const py = (event.clientY - rect.top) / rect.height;
const offsetX = (px - 0.5) * 6;
const offsetY = (py - 0.5) * 6;
const tiltX = (0.5 - py) * 8;
const tiltY = (px - 0.5) * 10;
surface.style.setProperty("--elastic-x", `${offsetX.toFixed(2)}px`);
surface.style.setProperty("--elastic-y", `${offsetY.toFixed(2)}px`);
surface.style.setProperty("--tilt-x", `${tiltX.toFixed(2)}deg`);
surface.style.setProperty("--tilt-y", `${tiltY.toFixed(2)}deg`);
surface.style.setProperty("--glow-x", `${(px * 100).toFixed(1)}%`);
surface.style.setProperty("--glow-y", `${(py * 100).toFixed(1)}%`);
surface.style.setProperty("--glow-opacity", "0.34");
});
}
bindListener(surface, "pointerenter", () => {
surface.style.setProperty("--glow-opacity", "0.28");
});
bindListener(surface, "pointerleave", () => {
resetSurface(surface);
});
bindListener(surface, "pointerdown", () => {
surface.classList.add("is-pressed");
});
bindListener(surface, "pointerup", () => {
surface.classList.remove("is-pressed");
});
bindListener(surface, "pointercancel", () => {
resetSurface(surface);
});
});
}
export function teardownControls() {
resetCleanup();
}
export function getAutoRotate() {
@@ -277,12 +497,12 @@ export function getAutoRotate() {
}
function updateRotateUI() {
const btn = document.getElementById('rotate-toggle');
const btn = document.getElementById("rotate-toggle");
if (btn) {
btn.classList.toggle('active', autoRotate);
btn.innerHTML = autoRotate ? '⏸️' : '▶️';
const tooltip = btn.querySelector('.tooltip');
if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '开始旋转';
btn.classList.toggle("active", autoRotate);
btn.classList.toggle("is-stopped", !autoRotate);
const tooltip = btn.querySelector(".tooltip");
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
}
}
@@ -294,9 +514,7 @@ export function setAutoRotate(value) {
export function toggleAutoRotate() {
autoRotate = !autoRotate;
updateRotateUI();
if (window.clearLockedCable) {
window.clearLockedCable();
}
clearLockedObject();
return autoRotate;
}
@@ -307,3 +525,24 @@ export function getZoomLevel() {
export function getShowTerrain() {
return showTerrain;
}
function updateLayoutUI(container) {
if (container) {
container.classList.toggle("layout-expanded", layoutExpanded);
}
const btn = document.getElementById("layout-toggle");
if (btn) {
btn.classList.toggle("active", layoutExpanded);
const tooltip = btn.querySelector(".tooltip");
const nextLabel = layoutExpanded ? "恢复布局" : "最大化布局";
btn.title = nextLabel;
if (tooltip) tooltip.textContent = nextLabel;
}
}
function toggleLayoutExpanded(container) {
layoutExpanded = !layoutExpanded;
updateLayoutUI(container);
return layoutExpanded;
}

View File

@@ -1,4 +1,5 @@
// info-card.js - Unified info card module
import { showStatusMessage } from './ui.js';
let currentType = null;
@@ -29,6 +30,39 @@ const CARD_CONFIG = {
{ key: 'apogee', label: '远地点', unit: 'km' }
]
},
bgp: {
icon: '📡',
title: 'BGP异常详情',
className: 'bgp',
fields: [
{ key: 'anomaly_type', label: '异常类型' },
{ key: 'severity', label: '严重度' },
{ key: 'status', label: '状态' },
{ key: 'route_change', label: '路由变更' },
{ key: 'prefix', label: '前缀' },
{ key: 'as_path_display', label: '传播路径' },
{ key: 'origin_asn', label: '原始 ASN' },
{ key: 'new_origin_asn', label: '新 ASN' },
{ key: 'confidence', label: '置信度' },
{ key: 'collector', label: '采集器' },
{ key: 'observed_by', label: '观测范围' },
{ key: 'impacted_scope', label: '影响区域' },
{ key: 'location', label: '观测位置' },
{ key: 'created_at', label: '发生时间' },
{ key: 'summary', label: '摘要' }
]
},
bgp_collector: {
icon: '📍',
title: 'BGP观测站详情',
className: 'bgp',
fields: [
{ key: 'collector', label: '采集器' },
{ key: 'location', label: '观测位置' },
{ key: 'anomaly_count', label: '当前异常数' },
{ key: 'status', label: '状态' }
]
},
supercomputer: {
icon: '🖥️',
title: '超算详情',
@@ -55,7 +89,32 @@ const CARD_CONFIG = {
};
export function initInfoCard() {
// Close button removed - now uses external clear button
const content = document.getElementById('info-card-content');
if (!content || content.dataset.copyBound === 'true') return;
content.addEventListener('click', async (event) => {
const label = event.target.closest('.info-card-label');
if (!label) return;
const property = label.closest('.info-card-property');
const valueEl = property?.querySelector('.info-card-value');
const value = valueEl?.textContent?.trim();
if (!value || value === '-') {
showStatusMessage('无可复制内容', 'warning');
return;
}
try {
await navigator.clipboard.writeText(value);
showStatusMessage(`已复制${label.textContent}${value}`, 'success');
} catch (error) {
console.error('Copy failed:', error);
showStatusMessage('复制失败', 'error');
}
});
content.dataset.copyBound = 'true';
}
export function setInfoCardNoBorder(noBorder = true) {

View File

@@ -0,0 +1,67 @@
const LEGEND_MODES = {
cables: {
title: "线缆图例",
},
satellites: {
title: "卫星图例",
},
bgp: {
title: "BGP观测图例",
},
};
let currentLegendMode = "cables";
let legendItemsByMode = {
cables: [],
satellites: [],
bgp: [],
};
export function initLegend() {
renderLegend(currentLegendMode);
}
export function setLegendMode(mode) {
const nextMode = LEGEND_MODES[mode] ? mode : "cables";
currentLegendMode = nextMode;
renderLegend(currentLegendMode);
}
export function getLegendMode() {
return currentLegendMode;
}
export function refreshLegend() {
renderLegend(currentLegendMode);
}
export function setLegendItems(mode, items) {
if (!LEGEND_MODES[mode]) return;
legendItemsByMode[mode] = Array.isArray(items) ? items : [];
if (mode === currentLegendMode) {
renderLegend(currentLegendMode);
}
}
function renderLegend(mode) {
const legend = document.getElementById("legend");
if (!legend) return;
const config = LEGEND_MODES[mode] || LEGEND_MODES.cables;
const items = legendItemsByMode[mode] || [];
const itemsHtml = items
.map(
(item) => `
<div class="legend-item">
<div class="legend-color" style="background-color: ${item.color};"></div>
<span>${item.label}</span>
</div>
`,
)
.join("");
legend.innerHTML = `
<h3 class="legend-title">${config.title}</h3>
<div class="legend-list">${itemsHtml}</div>
`;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,172 @@
// ui.js - UI update functions
let statusTimeoutId = null;
let statusHideTimeoutId = null;
let statusReplayTimeoutId = null;
// Show status message
export function showStatusMessage(message, type = 'info') {
const statusEl = document.getElementById('status-message');
statusEl.textContent = message;
statusEl.className = `status-message ${type}`;
statusEl.style.display = 'block';
setTimeout(() => {
statusEl.style.display = 'none';
}, 3000);
export function showStatusMessage(message, type = "info") {
const statusEl = document.getElementById("status-message");
if (!statusEl) return;
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
if (statusHideTimeoutId) {
clearTimeout(statusHideTimeoutId);
statusHideTimeoutId = null;
}
if (statusReplayTimeoutId) {
clearTimeout(statusReplayTimeoutId);
statusReplayTimeoutId = null;
}
const startShow = () => {
statusEl.textContent = message;
statusEl.className = `status-message ${type}`;
statusEl.style.display = "block";
statusEl.offsetHeight;
statusEl.classList.add("visible");
statusTimeoutId = setTimeout(() => {
statusEl.classList.remove("visible");
statusHideTimeoutId = setTimeout(() => {
statusEl.style.display = "none";
statusEl.textContent = "";
statusHideTimeoutId = null;
}, 280);
statusTimeoutId = null;
}, 3000);
};
if (statusEl.classList.contains("visible")) {
statusEl.classList.remove("visible");
statusReplayTimeoutId = setTimeout(() => {
startShow();
statusReplayTimeoutId = null;
}, 180);
return;
}
startShow();
}
// Update coordinates display
export function updateCoordinatesDisplay(lat, lon, alt = 0) {
document.getElementById('longitude-value').textContent = lon.toFixed(2) + '°';
document.getElementById('latitude-value').textContent = lat.toFixed(2) + '°';
document.getElementById('mouse-coords').textContent =
`鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
const longitudeEl = document.getElementById("longitude-value");
const latitudeEl = document.getElementById("latitude-value");
const mouseCoordsEl = document.getElementById("mouse-coords");
if (longitudeEl) longitudeEl.textContent = lon.toFixed(2) + "°";
if (latitudeEl) latitudeEl.textContent = lat.toFixed(2) + "°";
if (mouseCoordsEl) {
mouseCoordsEl.textContent = `鼠标: ${lat.toFixed(2)}°, ${lon.toFixed(2)}°`;
}
}
// Update zoom display
export function updateZoomDisplay(zoomLevel, distance) {
const percent = Math.round(zoomLevel * 100);
document.getElementById('zoom-value').textContent = percent + '%';
document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
const slider = document.getElementById('zoom-slider');
const zoomValueEl = document.getElementById("zoom-value");
const zoomLevelEl = document.getElementById("zoom-level");
const slider = document.getElementById("zoom-slider");
const cameraDistanceEl = document.getElementById("camera-distance");
if (zoomValueEl) zoomValueEl.textContent = percent + "%";
if (zoomLevelEl) zoomLevelEl.textContent = "缩放: " + percent + "%";
if (slider) slider.value = zoomLevel;
document.getElementById('camera-distance').textContent = distance + ' km';
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
}
// Update earth stats
export function updateEarthStats(stats) {
document.getElementById('cable-count').textContent = stats.cableCount || 0;
document.getElementById('landing-point-count').textContent = stats.landingPointCount || 0;
document.getElementById('terrain-status').textContent = stats.terrainOn ? '开启' : '关闭';
document.getElementById('texture-quality').textContent = stats.textureQuality || '8K 卫星图';
const cableCountEl = document.getElementById("cable-count");
const landingPointCountEl = document.getElementById("landing-point-count");
const bgpAnomalyCountEl = document.getElementById("bgp-anomaly-count");
const terrainStatusEl = document.getElementById("terrain-status");
const textureQualityEl = document.getElementById("texture-quality");
if (cableCountEl) cableCountEl.textContent = stats.cableCount || 0;
if (landingPointCountEl)
landingPointCountEl.textContent = stats.landingPointCount || 0;
if (bgpAnomalyCountEl)
bgpAnomalyCountEl.textContent = stats.bgpAnomalyCount || 0;
if (terrainStatusEl)
terrainStatusEl.textContent = stats.terrainOn ? "开启" : "关闭";
if (textureQualityEl)
textureQualityEl.textContent = stats.textureQuality || "8K 卫星图";
}
// Show/hide loading
export function setLoading(loading) {
const loadingEl = document.getElementById('loading');
loadingEl.style.display = loading ? 'block' : 'none';
const loadingEl = document.getElementById("loading");
if (!loadingEl) return;
loadingEl.style.display = loading ? "block" : "none";
}
export function setLoadingMessage(title, subtitle = "") {
const titleEl = document.getElementById("loading-title");
const subtitleEl = document.getElementById("loading-subtitle");
if (titleEl) {
titleEl.textContent = title;
}
if (subtitleEl) {
subtitleEl.textContent = subtitle;
}
}
// Show tooltip
export function showTooltip(x, y, content) {
const tooltip = document.getElementById('tooltip');
const tooltip = document.getElementById("tooltip");
if (!tooltip) return;
tooltip.innerHTML = content;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
tooltip.style.display = 'block';
tooltip.style.left = x + "px";
tooltip.style.top = y + "px";
tooltip.style.display = "block";
}
// Hide tooltip
export function hideTooltip() {
document.getElementById('tooltip').style.display = 'none';
const tooltip = document.getElementById("tooltip");
if (tooltip) {
tooltip.style.display = "none";
}
}
// Show error message
export function showError(message) {
const errorEl = document.getElementById('error-message');
const errorEl = document.getElementById("error-message");
if (!errorEl) return;
errorEl.textContent = message;
errorEl.style.display = 'block';
errorEl.style.display = "block";
}
// Hide error message
export function hideError() {
document.getElementById('error-message').style.display = 'none';
const errorEl = document.getElementById("error-message");
if (errorEl) {
errorEl.style.display = "none";
errorEl.textContent = "";
}
}
export function clearUiState() {
if (statusTimeoutId) {
clearTimeout(statusTimeoutId);
statusTimeoutId = null;
}
const statusEl = document.getElementById("status-message");
if (statusEl) {
statusEl.style.display = "none";
statusEl.textContent = "";
}
hideTooltip();
hideError();
}

View File

@@ -1,8 +1,8 @@
// utils.js - Utility functions for coordinate conversion
import * as THREE from 'three';
import * as THREE from "three";
import { CONFIG } from './constants.js';
import { CONFIG } from "./constants.js";
// Convert latitude/longitude to 3D vector
export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
@@ -18,26 +18,33 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
// Convert 3D vector to latitude/longitude
export function vector3ToLatLon(vector) {
const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI);
let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
const radius = Math.sqrt(
vector.x * vector.x + vector.y * vector.y + vector.z * vector.z,
);
const lat = 90 - (Math.acos(vector.y / radius) * 180) / Math.PI;
let lon = (Math.atan2(vector.z, -vector.x) * 180) / Math.PI - 180;
while (lon <= -180) lon += 360;
while (lon > 180) lon -= 360;
return {
lat: parseFloat(lat.toFixed(4)),
lon: parseFloat(lon.toFixed(4)),
alt: radius - CONFIG.earthRadius
alt: radius - CONFIG.earthRadius,
};
}
// Convert screen coordinates to Earth surface 3D coordinates
export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) {
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
export function screenToEarthCoords(
clientX,
clientY,
camera,
earth,
domElement = document.body,
raycaster = new THREE.Raycaster(),
mouse = new THREE.Vector2(),
) {
if (domElement === document.body) {
mouse.x = (clientX / window.innerWidth) * 2 - 1;
mouse.y = -(clientY / window.innerHeight) * 2 + 1;
@@ -60,17 +67,26 @@ export function screenToEarthCoords(clientX, clientY, camera, earth, domElement
}
// Calculate accurate spherical distance between two points (Haversine formula)
export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) {
export function calculateDistance(
lat1,
lon1,
lat2,
lon2,
radius = CONFIG.earthRadius,
) {
const toRad = (angle) => (angle * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return radius * c;
}

View File

@@ -7,6 +7,7 @@ import DataSources from './pages/DataSources/DataSources'
import DataList from './pages/DataList/DataList'
import Earth from './pages/Earth/Earth'
import Settings from './pages/Settings/Settings'
import BGP from './pages/BGP/BGP'
function App() {
const { token } = useAuthStore()
@@ -24,6 +25,7 @@ function App() {
<Route path="/users" element={<Users />} />
<Route path="/datasources" element={<DataSources />} />
<Route path="/data" element={<DataList />} />
<Route path="/bgp" element={<BGP />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -6,11 +6,13 @@ import {
UserOutlined,
SettingOutlined,
BarChartOutlined,
DeploymentUnitOutlined,
MenuUnfoldOutlined,
MenuFoldOutlined,
} from '@ant-design/icons'
import { Link, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import packageJson from '../../../package.json'
const { Sider, Content } = Layout
const { Text } = Typography
@@ -24,11 +26,13 @@ function AppLayout({ children }: AppLayoutProps) {
const { user, logout } = useAuthStore()
const [collapsed, setCollapsed] = useState(false)
const showBanner = true
const appVersion = `v${packageJson.version}`
const menuItems = [
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/"></Link> },
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources"></Link> },
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data"></Link> },
{ key: '/bgp', icon: <DeploymentUnitOutlined />, label: <Link to="/bgp">BGP观测</Link> },
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users"></Link> },
{ key: '/settings', icon: <SettingOutlined />, label: <Link to="/settings"></Link> },
]
@@ -74,6 +78,10 @@ function AppLayout({ children }: AppLayoutProps) {
<Text className="dashboard-sider-banner-label"></Text>
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
</div>
<div>
<Text className="dashboard-sider-banner-label"></Text>
<Text strong className="dashboard-sider-banner-value">{appVersion}</Text>
</div>
<Button type="primary" danger ghost block onClick={logout}>
退
</Button>

View File

@@ -173,7 +173,6 @@ body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -239,12 +238,71 @@ body {
gap: 12px;
}
.data-source-builtin-tab {
gap: 12px;
}
.data-source-custom-toolbar {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
}
.data-source-bulk-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(245, 247, 250, 0.96) 100%);
border: 1px solid rgba(5, 5, 5, 0.08);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
}
.data-source-bulk-toolbar__meta {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.data-source-bulk-toolbar__title {
font-size: 15px;
font-weight: 600;
color: #1f1f1f;
}
.data-source-bulk-toolbar__stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.data-source-bulk-toolbar__progress {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 520px;
}
.data-source-bulk-toolbar__progress-copy {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: #595959;
font-size: 13px;
}
.data-source-bulk-toolbar__progress-copy strong {
color: #1677ff;
font-size: 18px;
line-height: 1;
}
.data-source-table-region {
flex: 1 1 auto;
min-height: 0;
@@ -261,6 +319,10 @@ body {
min-height: 0;
}
.users-table-region .ant-table-body {
height: auto !important;
}
.data-source-table-region .ant-table-wrapper,
.data-source-table-region .ant-spin-nested-loading,
.data-source-table-region .ant-spin-container {
@@ -357,8 +419,8 @@ body {
.table-scroll-region .ant-table-body::-webkit-scrollbar,
.table-scroll-region .ant-table-content::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
}
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb,
@@ -380,6 +442,32 @@ body {
background: transparent;
}
.data-list-controls-shell {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
}
.data-list-controls-shell::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.data-list-controls-shell::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
.data-list-controls-shell::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.9);
background-clip: padding-box;
}
.data-list-controls-shell::-webkit-scrollbar-track {
background: transparent;
}
.settings-shell,
.settings-tabs-shell,
.settings-tabs,
@@ -532,6 +620,8 @@ body {
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.82) transparent;
}
.data-list-summary-card .ant-card-head,
@@ -545,6 +635,39 @@ body {
.data-list-summary-card-inner {
min-height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.data-list-summary-kpis {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.data-list-summary-kpi {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
border-radius: 14px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
border: 1px solid rgba(148, 163, 184, 0.22);
}
.data-list-summary-kpi__head,
.data-list-summary-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.data-list-summary-section-head {
margin-top: 4px;
}
.data-list-right-column {
@@ -580,6 +703,22 @@ body {
overflow: hidden;
}
.data-list-treemap-tile--compact {
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
}
.data-list-treemap-tile--compact .data-list-treemap-head {
justify-content: center;
}
.data-list-treemap-tile--compact .data-list-treemap-body {
margin-top: 0;
align-items: center;
}
.data-list-treemap-tile--ocean {
background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%);
}
@@ -660,12 +799,24 @@ body {
color: rgba(15, 23, 42, 0.72) !important;
}
.data-list-summary-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.8);
border: 1px dashed rgba(148, 163, 184, 0.35);
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar {
width: 10px;
width: 8px;
height: 8px;
}
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.8);
background: rgba(148, 163, 184, 0.82);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
@@ -868,6 +1019,13 @@ body {
gap: 10px;
}
.data-list-controls-shell {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scrollbar-gutter: stable;
}
.data-list-topbar {
align-items: flex-start;
flex-direction: column;
@@ -886,11 +1044,22 @@ body {
height: auto;
}
.data-list-summary-card--panel,
.data-list-summary-card--panel .ant-card-body,
.data-list-table-shell,
.data-list-table-shell .ant-card-body {
height: auto;
}
.data-list-summary-treemap {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: minmax(88px, 1fr);
}
.data-list-summary-kpis {
grid-template-columns: 1fr;
}
.data-list-filter-grid {
flex-wrap: wrap;
}
@@ -915,6 +1084,11 @@ body {
min-width: 100%;
}
.data-list-summary-section-head {
align-items: flex-start;
flex-direction: column;
}
}
.data-list-detail-modal {

View File

@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptio
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
interface Alert {
id: number
@@ -105,7 +106,7 @@ function Alerts() {
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
render: (t: string) => new Date(t).toLocaleString('zh-CN'),
render: (t: string) => formatDateTimeZhCN(t),
},
{
title: '操作',
@@ -201,15 +202,15 @@ function Alerts() {
</Descriptions.Item>
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
<Descriptions.Item label="创建时间">{new Date(selectedAlert.created_at).toLocaleString('zh-CN')}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
{selectedAlert.acknowledged_at && (
<Descriptions.Item label="确认时间">
{new Date(selectedAlert.acknowledged_at).toLocaleString('zh-CN')}
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
</Descriptions.Item>
)}
{selectedAlert.resolved_at && (
<Descriptions.Item label="解决时间">
{new Date(selectedAlert.resolved_at).toLocaleString('zh-CN')}
{formatDateTimeZhCN(selectedAlert.resolved_at)}
</Descriptions.Item>
)}
</Descriptions>

View File

@@ -0,0 +1,159 @@
import { useEffect, useState } from 'react'
import { Alert, Card, Col, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
interface BGPAnomaly {
id: number
source: string
anomaly_type: string
severity: string
status: string
prefix: string | null
origin_asn: number | null
new_origin_asn: number | null
confidence: number
summary: string
created_at: string | null
}
interface Summary {
total: number
by_type: Record<string, number>
by_severity: Record<string, number>
by_status: Record<string, number>
}
function severityColor(severity: string) {
if (severity === 'critical') return 'red'
if (severity === 'high') return 'orange'
if (severity === 'medium') return 'gold'
return 'blue'
}
function BGP() {
const [loading, setLoading] = useState(false)
const [anomalies, setAnomalies] = useState<BGPAnomaly[]>([])
const [summary, setSummary] = useState<Summary | null>(null)
useEffect(() => {
const load = async () => {
setLoading(true)
try {
const [anomaliesRes, summaryRes] = await Promise.all([
axios.get('/api/v1/bgp/anomalies', { params: { page_size: 100 } }),
axios.get('/api/v1/bgp/anomalies/summary'),
])
setAnomalies(anomaliesRes.data.data || [])
setSummary(summaryRes.data)
} finally {
setLoading(false)
}
}
load()
}, [])
return (
<AppLayout>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 4 }}>BGP观测</Title>
<Text type="secondary"></Text>
</div>
<Alert
type="info"
showIcon
message="该视图展示的是控制平面异常,不代表真实业务流量路径。"
/>
<Row gutter={16}>
<Col xs={24} md={8}>
<Card>
<Statistic title="异常总数" value={summary?.total || 0} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="Critical" value={summary?.by_severity?.critical || 0} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="Active" value={summary?.by_status?.active || 0} />
</Card>
</Col>
</Row>
<Card title="异常列表">
<Table<BGPAnomaly>
rowKey="id"
loading={loading}
dataSource={anomalies}
pagination={{ pageSize: 10 }}
columns={[
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '类型',
dataIndex: 'anomaly_type',
width: 180,
},
{
title: '严重度',
dataIndex: 'severity',
width: 120,
render: (value: string) => <Tag color={severityColor(value)}>{value}</Tag>,
},
{
title: '前缀',
dataIndex: 'prefix',
width: 180,
render: (value: string | null) => value || '-',
},
{
title: 'ASN',
key: 'asn',
width: 160,
render: (_, record) => {
if (record.origin_asn && record.new_origin_asn) {
return `AS${record.origin_asn} -> AS${record.new_origin_asn}`
}
if (record.origin_asn) {
return `AS${record.origin_asn}`
}
return '-'
},
},
{
title: '来源',
dataIndex: 'source',
width: 140,
},
{
title: '置信度',
dataIndex: 'confidence',
width: 120,
render: (value: number) => `${Math.round((value || 0) * 100)}%`,
},
{
title: '摘要',
dataIndex: 'summary',
},
]}
/>
</Card>
</Space>
</AppLayout>
)
}
export default BGP

View File

@@ -4,12 +4,15 @@ import {
DatabaseOutlined,
BarChartOutlined,
AlertOutlined,
GlobalOutlined,
WifiOutlined,
DisconnectOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
@@ -168,6 +171,21 @@ function Dashboard() {
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div>
<Title level={5} style={{ margin: 0 }}></Title>
<Text type="secondary">访</Text>
</div>
<Link to="/earth">
<Button type="primary" icon={<GlobalOutlined />}>
访 Earth
</Button>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
@@ -187,7 +205,7 @@ function Dashboard() {
{stats?.last_updated && (
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
: {new Date(stats.last_updated).toLocaleString('zh-CN')}
: {formatDateTimeZhCN(stats.last_updated)}
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}></Tag>}
</div>
)}

View File

@@ -1,16 +1,18 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import {
Table, Tag, Space, Card, Select, Input, Button,
Table, Tag, Space, Card, Select, Input, Button, Segmented,
Modal, Spin, Empty, Tooltip, Typography, Grid
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { CustomTagProps } from 'rc-select/lib/BaseSelect'
import {
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined,
ApartmentOutlined, EnvironmentOutlined
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN, formatDateZhCN, parseBackendDate } from '../../utils/datetime'
const { Title, Text } = Typography
const { useBreakpoint } = Grid
@@ -18,6 +20,7 @@ const { useBreakpoint } = Grid
interface CollectedData {
id: number
source: string
source_name: string
source_id: string
data_type: string
name: string
@@ -41,8 +44,15 @@ interface CollectedData {
interface Summary {
total_records: number
overall_total_records: number
by_source: Record<string, Record<string, number>>
source_totals: Array<{ source: string; count: number }>
source_totals: Array<{ source: string; source_name: string; count: number }>
type_totals: Array<{ data_type: string; count: number }>
}
interface SourceOption {
source: string
source_name: string
}
const DETAIL_FIELD_LABELS: Record<string, string> = {
@@ -111,12 +121,15 @@ function formatDetailValue(key: string, value: unknown) {
}
if (key === 'collected_at' || key === 'reference_date') {
const date = new Date(String(value))
const date = parseBackendDate(String(value))
if (!date) {
return String(value)
}
return Number.isNaN(date.getTime())
? String(value)
: key === 'reference_date'
? date.toLocaleDateString('zh-CN')
: date.toLocaleString('zh-CN')
? formatDateZhCN(String(value))
: formatDateTimeZhCN(String(value))
}
if (typeof value === 'boolean') {
@@ -130,6 +143,13 @@ function formatDetailValue(key: string, value: unknown) {
return String(value)
}
function getDetailFieldValue(detailData: CollectedData, key: string): unknown {
if (key === 'source') {
return detailData.source_name || detailData.source
}
return detailData[key as keyof CollectedData]
}
function NameMarquee({ text }: { text: string }) {
const containerRef = useRef<HTMLSpanElement | null>(null)
const textRef = useRef<HTMLSpanElement | null>(null)
@@ -222,6 +242,56 @@ function estimateTreemapRows(
return Math.max(occupancy.length, 1)
}
function getTreemapSpan(value: number, maxValue: number, columns: number) {
if (columns <= 1) return 1
const normalized = Math.log10(value + 1) / Math.log10(maxValue + 1)
if (columns >= 4 && normalized >= 0.94) return 3
if (normalized >= 0.62) return 2
return 1
}
function isCompactTreemapItem(item: { colSpan: number; rowSpan: number }) {
return item.colSpan === 1 && item.rowSpan === 1
}
function getTreemapColumnCount(
width: number,
minCellSize: number,
gap: number,
isCompact: boolean
) {
const visualCap = isCompact ? 4 : 8
if (width <= 0) return Math.min(visualCap, isCompact ? 2 : 4)
const maxColumnsByWidth = Math.max(1, Math.floor((width + gap) / (minCellSize + gap)))
return Math.max(1, Math.min(maxColumnsByWidth, visualCap))
}
function getTreemapBaseSize(width: number, columns: number, gap: number, minCellSize: number) {
const fittedSize = Math.floor((Math.max(width, 0) - Math.max(0, columns - 1) * gap) / columns)
return Math.max(minCellSize, fittedSize || minCellSize)
}
function getTreemapTypography(rowHeight: number) {
const tilePadding = rowHeight <= 72 ? 8 : rowHeight <= 84 ? 10 : 12
const labelSize = rowHeight <= 72 ? 10 : rowHeight <= 84 ? 11 : 12
const valueSize = rowHeight <= 72 ? 13 : rowHeight <= 84 ? 15 : 16
return { tilePadding, labelSize, valueSize }
}
function getTreemapItemValueSize(
item: { colSpan: number; rowSpan: number },
baseValueSize: number
) {
if (isCompactTreemapItem(item)) {
return Math.max(11, baseValueSize - 2)
}
return baseValueSize
}
function DataList() {
const screens = useBreakpoint()
const isCompact = !screens.lg
@@ -239,6 +309,7 @@ function DataList() {
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
const [summaryBodyHeight, setSummaryBodyHeight] = useState(0)
const [summaryBodyWidth, setSummaryBodyWidth] = useState(0)
const [data, setData] = useState<CollectedData[]>([])
const [loading, setLoading] = useState(false)
@@ -249,11 +320,12 @@ function DataList() {
const [sourceFilter, setSourceFilter] = useState<string[]>([])
const [typeFilter, setTypeFilter] = useState<string[]>([])
const [searchText, setSearchText] = useState('')
const [sources, setSources] = useState<string[]>([])
const [sources, setSources] = useState<SourceOption[]>([])
const [types, setTypes] = useState<string[]>([])
const [detailVisible, setDetailVisible] = useState(false)
const [detailData, setDetailData] = useState<CollectedData | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [treemapDimension, setTreemapDimension] = useState<'source' | 'type'>('source')
useEffect(() => {
const updateLayout = () => {
@@ -262,6 +334,7 @@ function DataList() {
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
setSummaryBodyWidth(summaryBodyRef.current?.offsetWidth || 0)
}
updateLayout()
@@ -289,7 +362,7 @@ function DataList() {
const minLeft = 260
const minRight = 360
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 3), maxLeft))
setLeftPanelWidth((current) => {
if (!hasCustomLeftWidthRef.current) {
@@ -347,7 +420,13 @@ function DataList() {
const fetchSummary = async () => {
try {
const res = await axios.get('/api/v1/collected/summary')
const params = new URLSearchParams()
if (sourceFilter.length > 0) params.append('source', sourceFilter.join(','))
if (typeFilter.length > 0) params.append('data_type', typeFilter.join(','))
if (searchText) params.append('search', searchText)
const query = params.toString()
const res = await axios.get(query ? `/api/v1/collected/summary?${query}` : '/api/v1/collected/summary')
setSummary(res.data)
} catch (error) {
console.error('Failed to fetch summary:', error)
@@ -368,17 +447,18 @@ function DataList() {
}
useEffect(() => {
fetchSummary()
fetchFilters()
}, [])
useEffect(() => {
fetchData()
fetchSummary()
}, [page, pageSize, sourceFilter, typeFilter])
const handleSearch = () => {
setPage(1)
fetchData()
fetchSummary()
}
const handleReset = () => {
@@ -387,6 +467,7 @@ function DataList() {
setSearchText('')
setPage(1)
setTimeout(fetchData, 0)
setTimeout(fetchSummary, 0)
}
const handleViewDetail = async (id: number) => {
@@ -414,16 +495,67 @@ function DataList() {
return iconMap[source] || <DatabaseOutlined />
}
const getDataTypeIcon = (dataType: string) => {
const iconMap: Record<string, React.ReactNode> = {
supercomputer: <CloudServerOutlined />,
gpu_cluster: <CloudServerOutlined />,
model: <AppstoreOutlined />,
dataset: <DatabaseOutlined />,
space: <AppstoreOutlined />,
submarine_cable: <GlobalOutlined />,
cable_landing_point: <EnvironmentOutlined />,
cable_landing_relation: <GlobalOutlined />,
ixp: <ApartmentOutlined />,
network: <GlobalOutlined />,
facility: <ApartmentOutlined />,
generic: <DatabaseOutlined />,
}
return iconMap[dataType] || <DatabaseOutlined />
}
const getSourceTagColor = (source: string) => {
const colorMap: Record<string, string> = {
top500: 'geekblue',
huggingface_models: 'purple',
huggingface_datasets: 'cyan',
huggingface_spaces: 'magenta',
peeringdb_ixp: 'gold',
peeringdb_network: 'orange',
peeringdb_facility: 'lime',
telegeography_cables: 'green',
telegeography_landing: 'green',
telegeography_systems: 'emerald',
arcgis_cables: 'blue',
arcgis_landing_points: 'cyan',
arcgis_cable_landing_relations: 'volcano',
fao_landing_points: 'processing',
epoch_ai_gpu: 'volcano',
ris_live_bgp: 'red',
bgpstream_bgp: 'purple',
cloudflare_radar_device: 'magenta',
cloudflare_radar_traffic: 'orange',
cloudflare_radar_top_as: 'gold',
}
return colorMap[source] || 'blue'
if (colorMap[source]) {
return colorMap[source]
}
const fallbackPalette = [
'blue',
'geekblue',
'cyan',
'green',
'lime',
'gold',
'orange',
'volcano',
'magenta',
'purple',
]
const hash = Array.from(source).reduce((acc, char) => acc + char.charCodeAt(0), 0)
return fallbackPalette[hash % fallbackPalette.length]
}
const getDataTypeTagColor = (dataType: string) => {
@@ -455,103 +587,86 @@ function DataList() {
)
}
const getTypeColor = (type: string) => {
const colors: Record<string, string> = {
supercomputer: 'red',
model: 'blue',
dataset: 'green',
space: 'purple',
submarine_cable: 'cyan',
gpu_cluster: 'orange',
ixp: 'magenta',
network: 'gold',
facility: 'lime',
}
return colors[type] || 'default'
}
const activeFilterCount = useMemo(
() => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length,
[sourceFilter, typeFilter, searchText]
)
const summaryItems = useMemo(() => {
const items = [
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
const summaryKpis = useMemo(
() => [
{ key: 'total', label: '总记录', value: summary?.overall_total_records || 0, icon: <DatabaseOutlined /> },
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
{ key: 'sources', label: '数据源数', value: sources.length, icon: <DatabaseOutlined /> },
]
{
key: 'coverage',
label: treemapDimension === 'source' ? '覆盖数据源' : '覆盖类型',
value: treemapDimension === 'source'
? summary?.source_totals?.length || 0
: summary?.type_totals?.length || 0,
icon: treemapDimension === 'source' ? <DatabaseOutlined /> : <AppstoreOutlined />,
},
],
[summary, total, activeFilterCount, treemapDimension]
)
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
items.push({
key: item.source,
label: item.source,
const distributionItems = useMemo(() => {
if (!summary) return []
if (treemapDimension === 'type') {
return summary.type_totals.map((item) => ({
key: item.data_type,
label: item.data_type,
value: item.count,
icon: getSourceIcon(item.source),
})
icon: getDataTypeIcon(item.data_type),
}))
}
return items
}, [summary, total, activeFilterCount, isCompact, sources.length])
return summary.source_totals.map((item) => ({
key: item.source,
label: item.source_name,
value: item.count,
icon: getSourceIcon(item.source),
}))
}, [summary, treemapDimension])
const treemapGap = isCompact ? 8 : 10
const treemapMinCellSize = isCompact ? 72 : 52
const treemapColumns = useMemo(() => {
if (isCompact) return 1
if (leftPanelWidth < 360) return 2
if (leftPanelWidth < 520) return 3
return 4
}, [isCompact, leftPanelWidth])
return getTreemapColumnCount(summaryBodyWidth, treemapMinCellSize, treemapGap, isCompact)
}, [isCompact, summaryBodyWidth, treemapGap, treemapMinCellSize])
const treemapItems = useMemo(() => {
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2
const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520
const maxItems = isCompact ? 6 : 10
const limitedItems = distributionItems.slice(0, maxItems)
const maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
return summaryItems.map((item, index) => {
const ratio = item.value / maxValue
let colSpan = 1
let rowSpan = 1
if (allowFeaturedTile && index === 0) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowSecondaryTallTiles && ratio >= 0.7) {
colSpan = Math.min(2, treemapColumns)
rowSpan = 2
} else if (allowSecondaryTallTiles && ratio >= 0.35) {
rowSpan = 2
}
return limitedItems.map((item, index) => {
const span = Math.min(getTreemapSpan(item.value, maxValue, treemapColumns), treemapColumns)
return {
...item,
colSpan,
rowSpan,
colSpan: span,
rowSpan: span,
tone: palette[index % palette.length],
}
})
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
}, [distributionItems, isCompact, treemapColumns])
const treemapRows = useMemo(
() => estimateTreemapRows(treemapItems, treemapColumns),
[treemapColumns, treemapItems]
)
const treemapGap = isCompact ? 8 : 10
const treemapMinRowHeight = isCompact ? 88 : 68
const treemapTargetRowHeight = isCompact ? 88 : leftPanelWidth < 360 ? 44 : leftPanelWidth < 520 ? 48 : 56
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
const treemapAutoRowHeight = treemapRows > 0
? Math.floor((treemapAvailableHeight - Math.max(0, treemapRows - 1) * treemapGap) / treemapRows)
: treemapTargetRowHeight
const treemapRowHeight = Math.max(
treemapMinRowHeight,
Math.min(treemapTargetRowHeight, treemapAutoRowHeight || treemapTargetRowHeight)
const treemapBaseSize = Math.max(
treemapMinCellSize,
getTreemapBaseSize(summaryBodyWidth, treemapColumns, treemapGap, treemapMinCellSize)
)
const treemapAvailableHeight = Math.max(summaryBodyHeight, 0)
const treemapRowHeight = treemapBaseSize
const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12
const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12
const treemapValueSize = treemapRowHeight <= 72 ? 13 : treemapRowHeight <= 84 ? 15 : 16
const { tilePadding: treemapTilePadding, labelSize: treemapLabelSize, valueSize: treemapValueSize } =
getTreemapTypography(treemapRowHeight)
const pageHeight = '100%'
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
@@ -564,7 +679,7 @@ function DataList() {
return DETAIL_BASE_FIELDS.map((key) => ({
key,
label: formatFieldLabel(key),
value: formatDetailValue(key, detailData[key as keyof CollectedData]),
value: formatDetailValue(key, getDetailFieldValue(detailData, key)),
})).filter((item) => item.value !== '-')
}, [detailData])
@@ -605,11 +720,11 @@ function DataList() {
dataIndex: 'source',
key: 'source',
minWidth: 140,
render: (value: string) => (
value ? (
render: (_: string, record: CollectedData) => (
record.source ? (
<div className="data-list-tag-cell">
<Tag color={getSourceTagColor(value)} style={{ marginInlineEnd: 0 }}>
{value}
<Tag color={getSourceTagColor(record.source)} style={{ marginInlineEnd: 0 }}>
{record.source_name || record.source}
</Tag>
</div>
) : '-'
@@ -635,14 +750,14 @@ function DataList() {
dataIndex: 'collected_at',
key: 'collected_at',
width: 180,
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
render: (time: string) => formatDateTimeZhCN(time),
},
{
title: '参考日期',
dataIndex: 'reference_date',
key: 'reference_date',
width: 120,
render: (time: string | null) => (time ? new Date(time).toLocaleDateString('zh-CN') : '-'),
render: (time: string | null) => formatDateZhCN(time),
},
{
title: '操作',
@@ -682,6 +797,31 @@ function DataList() {
styles={{ body: { padding: isCompact ? 12 : 16 } }}
>
<div ref={summaryBodyRef} className="data-list-summary-card-inner">
<div className="data-list-summary-kpis">
{summaryKpis.map((item) => (
<div key={item.key} className="data-list-summary-kpi">
<div className="data-list-summary-kpi__head">
<span className="data-list-summary-tile-icon">{item.icon}</span>
<Text className="data-list-treemap-label">{item.label}</Text>
</div>
<Text strong className="data-list-summary-tile-value">
{item.value.toLocaleString()}
</Text>
</div>
))}
</div>
<div className="data-list-summary-section-head">
<Text strong></Text>
<Segmented
size="small"
value={treemapDimension}
onChange={(value) => setTreemapDimension(value as 'source' | 'type')}
options={[
{ label: '按数据源', value: 'source' },
{ label: '按类型', value: 'type' },
]}
/>
</div>
<div
className="data-list-summary-treemap"
style={{
@@ -695,26 +835,40 @@ function DataList() {
['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`,
} as CSSProperties}
>
{treemapItems.map((item) => (
{treemapItems.length > 0 ? treemapItems.map((item) => (
<div
key={item.key}
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}${isCompactTreemapItem(item) ? ' data-list-treemap-tile--compact' : ''}`}
style={{
gridColumn: `span ${item.colSpan}`,
gridRow: `span ${item.rowSpan}`,
}}
>
<div className="data-list-treemap-head">
<span className="data-list-summary-tile-icon">{item.icon}</span>
<Text className="data-list-treemap-label">{item.label}</Text>
<Tooltip title={item.label}>
<span className="data-list-summary-tile-icon">{item.icon}</span>
</Tooltip>
{!isCompactTreemapItem(item) ? (
<Tooltip title={item.label}>
<Text className="data-list-treemap-label">{item.label}</Text>
</Tooltip>
) : null}
</div>
<div className="data-list-treemap-body">
<Text strong className="data-list-summary-tile-value">
<Text
strong
className="data-list-summary-tile-value"
style={{ fontSize: getTreemapItemValueSize(item, treemapValueSize) }}
>
{item.value.toLocaleString()}
</Text>
</div>
</div>
))}
)) : (
<div className="data-list-summary-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无分布数据" />
</div>
)}
</div>
</div>
</Card>
@@ -756,7 +910,7 @@ function DataList() {
setSourceFilter(value)
setPage(1)
}}
options={sources.map((source) => ({ label: source, value: source }))}
options={sources.map((source) => ({ label: source.source_name, value: source.source }))}
tagRender={(tagProps) => renderFilterTag(tagProps, getSourceTagColor)}
style={{ width: '100%' }}
className="data-list-filter-select"

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Table, Tag, Space, message, Button, Form, Input, Select,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
Table, Tag, Space, message, Button, Form, Input, Select, Progress, Checkbox,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
} from 'antd'
import {
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
@@ -11,6 +11,8 @@ import {
} from '@ant-design/icons'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useWebSocket } from '../../hooks/useWebSocket'
interface BuiltInDataSource {
id: number
@@ -22,6 +24,10 @@ interface BuiltInDataSource {
is_active: boolean
collector_class: string
last_run: string | null
last_run_at?: string | null
last_status?: string | null
last_records_processed?: number | null
data_count?: number
is_running: boolean
task_id: number | null
progress: number | null
@@ -38,6 +44,22 @@ interface TaskTrackerState {
status?: string | null
records_processed?: number | null
total_records?: number | null
error_message?: string | null
}
interface WebSocketTaskMessage {
type: string
channel?: string
payload?: {
datasource_id?: number
task_id?: number | null
progress?: number | null
phase?: string | null
status?: string | null
records_processed?: number | null
total_records?: number | null
error_message?: string | null
}
}
interface CustomDataSource {
@@ -78,6 +100,8 @@ function DataSources() {
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false)
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
const [forceTriggerAll, setForceTriggerAll] = useState(false)
const [testResult, setTestResult] = useState<any>(null)
const builtinTableRegionRef = useRef<HTMLDivElement | null>(null)
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
@@ -85,7 +109,7 @@ function DataSources() {
const [customTableHeight, setCustomTableHeight] = useState(360)
const [form] = Form.useForm()
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [builtinRes, customRes] = await Promise.all([
@@ -99,13 +123,72 @@ function DataSources() {
} finally {
setLoading(false)
}
}
}, [])
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
const runningBuiltInCount = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return trackedTask?.is_running || source.is_running
}).length
const runningBuiltInSources = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return trackedTask?.is_running || source.is_running
})
const aggregateProgress = runningBuiltInSources.length > 0
? Math.round(
runningBuiltInSources.reduce((sum, source) => {
const trackedTask = taskProgress[source.id]
return sum + (trackedTask?.progress ?? source.progress ?? 0)
}, 0) / runningBuiltInSources.length
)
: 0
const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => {
if (message.type !== 'data_frame' || message.channel !== 'datasource_tasks' || !message.payload?.datasource_id) {
return
}
const payload = message.payload
const sourceId = payload.datasource_id
const nextState: TaskTrackerState = {
task_id: payload.task_id ?? null,
progress: payload.progress ?? 0,
is_running: payload.status === 'running',
phase: payload.phase ?? null,
status: payload.status ?? null,
records_processed: payload.records_processed ?? null,
total_records: payload.total_records ?? null,
error_message: payload.error_message ?? null,
}
setTaskProgress((prev) => {
const next = {
...prev,
[sourceId]: nextState,
}
if (!nextState.is_running && nextState.status !== 'running') {
delete next[sourceId]
}
return next
})
if (payload.status && payload.status !== 'running') {
void fetchData()
}
}, [fetchData])
const { connected: taskSocketConnected } = useWebSocket({
autoConnect: true,
autoSubscribe: ['datasource_tasks'],
onMessage: handleTaskSocketMessage,
})
useEffect(() => {
fetchData()
}, [])
}, [fetchData])
useEffect(() => {
const updateHeights = () => {
@@ -130,6 +213,8 @@ function DataSources() {
}, [activeTab, builtInSources.length, customSources.length])
useEffect(() => {
if (taskSocketConnected) return
const trackedSources = builtInSources.filter((source) => {
const trackedTask = taskProgress[source.id]
return Boolean((trackedTask?.task_id ?? source.task_id) && (trackedTask?.is_running ?? source.is_running))
@@ -186,22 +271,28 @@ function DataSources() {
}, 2000)
return () => clearInterval(interval)
}, [builtInSources, taskProgress])
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
const handleTrigger = async (id: number) => {
try {
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
message.success('任务已触发')
setTaskProgress(prev => ({
...prev,
[id]: {
task_id: res.data.task_id ?? null,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
},
}))
if (res.data.task_id) {
setTaskProgress(prev => ({
...prev,
[id]: {
task_id: res.data.task_id,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
},
}))
} else {
window.setTimeout(() => {
fetchData()
}, 800)
}
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
@@ -209,6 +300,52 @@ function DataSources() {
}
}
const handleTriggerAll = async () => {
try {
setTriggerAllLoading(true)
const res = await axios.post('/api/v1/datasources/trigger-all', null, {
params: { force: forceTriggerAll },
})
const triggered = res.data.triggered || []
const skipped = res.data.skipped || []
const failed = res.data.failed || []
const skippedInWindow = skipped.filter((item: { reason?: string }) => item.reason === 'within_frequency_window')
const skippedOther = skipped.filter((item: { reason?: string }) => item.reason !== 'within_frequency_window')
if (triggered.length > 0) {
setTaskProgress((prev) => {
const next = { ...prev }
for (const item of triggered) {
if (!item.task_id) continue
next[item.id] = {
task_id: item.task_id,
progress: 0,
is_running: true,
phase: 'queued',
status: 'running',
}
}
return next
})
}
const summaryParts = [
`已触发 ${triggered.length}`,
skippedInWindow.length > 0 ? `周期内跳过 ${skippedInWindow.length}` : null,
skippedOther.length > 0 ? `其他跳过 ${skippedOther.length}` : null,
failed.length > 0 ? `失败 ${failed.length}` : null,
].filter(Boolean)
message.success(summaryParts.join(''))
fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || '全触发失败')
} finally {
setTriggerAllLoading(false)
}
}
const handleToggle = async (id: number, current: boolean) => {
const endpoint = current ? 'disable' : 'enable'
try {
@@ -405,8 +542,15 @@ function DataSources() {
title: '最近采集',
dataIndex: 'last_run',
key: 'last_run',
width: 140,
render: (lastRun: string | null) => lastRun || '-',
width: 180,
render: (_: string | null, record: BuiltInDataSource) => {
const label = formatDateTimeZhCN(record.last_run_at || record.last_run)
if (!label || label === '-') return '-'
if ((record.data_count || 0) === 0 && record.last_status === 'success') {
return `${label} (0条)`
}
return label
},
},
{
title: '状态',
@@ -431,7 +575,6 @@ function DataSources() {
const phase = taskState?.phase || record.phase || 'queued'
return (
<Space size={6} wrap>
<Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
<Tag color="processing">
{phaseLabelMap[phase] || phase}
{pct > 0 ? ` ${Math.round(pct)}%` : ''}
@@ -439,7 +582,26 @@ function DataSources() {
</Space>
)
}
return <Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
const lastStatusColor =
record.last_status === 'success'
? 'success'
: record.last_status === 'failed'
? 'error'
: 'default'
return (
<Space size={6} wrap>
{record.last_status ? (
<Tag color={lastStatusColor}>
{record.last_status === 'success'
? '采集成功'
: record.last_status === 'failed'
? '采集失败'
: record.last_status}
</Tag>
) : null}
</Space>
)
},
},
{
@@ -453,6 +615,7 @@ function DataSources() {
type="link"
size="small"
icon={<SyncOutlined />}
disabled={!record.is_active}
onClick={() => handleTrigger(record.id)}
>
@@ -461,6 +624,8 @@ function DataSources() {
type="link"
size="small"
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
danger={record.is_active}
style={record.is_active ? undefined : { color: '#52c41a' }}
onClick={() => handleToggle(record.id, record.is_active)}
>
{record.is_active ? '禁用' : '启用'}
@@ -536,7 +701,47 @@ function DataSources() {
key: 'builtin',
label: '内置数据源',
children: (
<div className="page-shell__body">
<div className="page-shell__body data-source-builtin-tab">
<div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div>
<div className="data-source-bulk-toolbar__progress">
<div className="data-source-bulk-toolbar__progress-copy">
<span></span>
<strong>{aggregateProgress}%</strong>
</div>
<Progress
percent={aggregateProgress}
size="small"
status={runningBuiltInCount > 0 ? 'active' : 'normal'}
showInfo={false}
strokeColor="#1677ff"
/>
</div>
<div className="data-source-bulk-toolbar__stats">
<Tag color="blue"> {builtInSources.length}</Tag>
<Tag color="green"> {activeBuiltInCount}</Tag>
<Tag color="processing"> {runningBuiltInCount}</Tag>
</div>
</div>
<Space size={12} align="center">
<Checkbox
checked={forceTriggerAll}
onChange={(event) => setForceTriggerAll(event.target.checked)}
>
</Checkbox>
<Button
type="primary"
size="middle"
icon={<SyncOutlined />}
loading={triggerAllLoading}
onClick={handleTriggerAll}
>
</Button>
</Space>
</div>
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={builtinColumns}
@@ -854,80 +1059,87 @@ function DataSources() {
}
>
{viewingSource && (
<Form layout="vertical">
<Form.Item label="名称">
<Input value={viewingSource.name} disabled />
</Form.Item>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small" bordered={false} style={{ background: '#fafafa' }}>
<Row gutter={[12, 12]}>
<Col span={24}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.name} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.module} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.priority} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.frequency} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={`${recordCount}`} disabled />
</Col>
<Col span={24}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.collector_class} disabled />
</Col>
</Row>
</Card>
<Form.Item label="数据量">
<Input value={`${recordCount}`} disabled />
</Form.Item>
<Form layout="vertical">
<Form.Item label="采集源 API 链接">
<Space.Compact style={{ width: '100%' }}>
<Input value={viewingSource.endpoint || '-'} readOnly />
<Tooltip title={viewingSource.endpoint ? '复制采集源 API 链接' : '当前没有可复制的采集源 API 链接'}>
<Button
disabled={!viewingSource.endpoint}
icon={<CopyOutlined />}
onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Form.Item label="采集器">
<Input value={viewingSource.collector_class} disabled />
</Form.Item>
<Form.Item label="模块">
<Input value={viewingSource.module} disabled />
</Form.Item>
<Form.Item label="优先级">
<Input value={viewingSource.priority} disabled />
</Form.Item>
<Form.Item label="频率">
<Input value={viewingSource.frequency} disabled />
</Form.Item>
<Form.Item label="采集源 API 链接">
<Space.Compact style={{ width: '100%' }}>
<Input value={viewingSource.endpoint || '-'} readOnly />
<Tooltip title={viewingSource.endpoint ? '复制采集源 API 链接' : '当前没有可复制的采集源 API 链接'}>
<Button
disabled={!viewingSource.endpoint}
icon={<CopyOutlined />}
onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Collapse
items={[
{
key: 'auth',
label: '认证配置',
children: (
<Form.Item label="认证方式">
<Input value={viewingSource.auth_type || 'none'} disabled />
</Form.Item>
),
},
{
key: 'headers',
label: '请求头',
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
{JSON.stringify(viewingSource.headers, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
{
key: 'config',
label: '高级配置',
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
{JSON.stringify(viewingSource.config, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
]}
/>
</Form>
<Collapse
items={[
{
key: 'auth',
label: '认证配置',
children: (
<Form.Item label="认证方式" style={{ marginBottom: 0 }}>
<Input value={viewingSource.auth_type || 'none'} disabled />
</Form.Item>
),
},
{
key: 'headers',
label: '请求头',
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
{JSON.stringify(viewingSource.headers, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
{
key: 'config',
label: '高级配置',
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
{JSON.stringify(viewingSource.config, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
]}
/>
</Form>
</Space>
)}
</Drawer>
</AppLayout>

View File

@@ -3,14 +3,14 @@ function Earth() {
<iframe
src="/earth/index.html"
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block',
width: "100%",
height: "100%",
border: "none",
display: "block",
}}
title="3D Earth"
/>
)
);
}
export default Earth
export default Earth;

View File

@@ -16,6 +16,7 @@ import {
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
const { Title, Text } = Typography
@@ -220,14 +221,14 @@ function Settings() {
dataIndex: 'last_run_at',
key: 'last_run_at',
width: 180,
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '下次执行',
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 180,
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
render: (value: string | null) => formatDateTimeZhCN(value),
},
{
title: '状态',

View File

@@ -3,6 +3,7 @@ import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import { formatDateTimeZhCN } from '../../utils/datetime'
interface Task {
id: number
@@ -93,7 +94,7 @@ function Tasks() {
title: '开始时间',
dataIndex: 'started_at',
key: 'started_at',
render: (t: string) => t ? new Date(t).toLocaleString('zh-CN') : '-',
render: (t: string) => formatDateTimeZhCN(t),
},
]

View File

@@ -144,7 +144,7 @@ function Users() {
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</div>
<div className="page-shell__body">
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region" style={{ height: '100%' }}>
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region users-table-region" style={{ height: '100%' }}>
<Table
columns={columns}
dataSource={users}

View File

@@ -0,0 +1,47 @@
export function parseBackendDate(value: string | null | undefined): Date | null {
if (!value) return null
let normalized = value.trim()
if (!normalized) return null
if (normalized.includes(' ') && !normalized.includes('T')) {
normalized = normalized.replace(' ', 'T')
}
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/.test(normalized)
if (!hasTimezone) {
normalized = `${normalized}Z`
}
const date = new Date(normalized)
return Number.isNaN(date.getTime()) ? null : date
}
function padNumber(value: number): string {
return String(value).padStart(2, '0')
}
export function formatDateTimeZhCN(value: string | null | undefined): string {
const date = parseBackendDate(value)
if (!date) return '-'
const year = date.getFullYear()
const month = padNumber(date.getMonth() + 1)
const day = padNumber(date.getDate())
const hours = padNumber(date.getHours())
const minutes = padNumber(date.getMinutes())
const seconds = padNumber(date.getSeconds())
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`
}
export function formatDateZhCN(value: string | null | undefined): string {
const date = parseBackendDate(value)
if (!date) return '-'
const year = date.getFullYear()
const month = padNumber(date.getMonth() + 1)
const day = padNumber(date.getDate())
return `${year}/${month}/${day}`
}

View File

@@ -64,6 +64,7 @@ export default defineConfig({
},
},
optimizeDeps: {
entries: ['src/main.tsx'],
exclude: ['satellite.js'],
},
})

View File

@@ -1,40 +0,0 @@
#!/bin/bash
# kill_port.sh - 检测并杀掉占用指定端口的进程
PORT=${1:-8000}
echo "=========================================="
echo "端口检测与清理脚本"
echo "=========================================="
echo "目标端口: $PORT"
echo ""
# 检测并杀掉占用端口的进程
echo "[1/3] 检测端口 $PORT ..."
if fuser $PORT/tcp >/dev/null 2>&1; then
echo " ✓ 发现占用端口 $PORT 的进程:"
fuser -v $PORT/tcp 2>&1
echo ""
echo "[2/3] 正在终止进程..."
fuser -k $PORT/tcp 2>&1
echo " ✓ 进程已终止"
else
echo " ✓ 端口 $PORT 未被占用"
fi
echo ""
echo "[3/3] 清理残留Docker容器..."
EXIT_CONTAINERS=$(docker ps -a | grep Exit | awk '{print $1}')
if [ -n "$EXIT_CONTAINERS" ]; then
echo " 发现残留容器,正在清理..."
echo "$EXIT_CONTAINERS" | xargs -r docker rm -f
echo " ✓ 残留容器已清理"
else
echo " ✓ 无残留容器"
fi
echo ""
echo "=========================================="
echo "完成!"
echo "=========================================="

380
planet.sh
View File

@@ -11,6 +11,15 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
DEFAULT_BACKEND_PORT="${DEFAULT_BACKEND_PORT:-8000}"
DEFAULT_FRONTEND_PORT="${DEFAULT_FRONTEND_PORT:-3000}"
ensure_uv_backend_deps() {
echo -e "${BLUE}📦 检查后端 uv 环境...${NC}"
@@ -53,57 +62,362 @@ ensure_frontend_deps() {
fi
}
start() {
echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
wait_for_http() {
local url="$1"
local attempts="$2"
local interval="$3"
local service_name="$4"
local attempt=1
while [ "$attempt" -le "$attempts" ]; do
if curl -s "$url" > /dev/null 2>&1; then
return 0
fi
echo -e "${YELLOW}⏳ 等待${service_name}就绪 (${attempt}/${attempts})...${NC}"
sleep "$interval"
attempt=$((attempt + 1))
done
return 1
}
start_backend_with_retry() {
local backend_port="$1"
local retry=1
while [ "$retry" -le "$BACKEND_MAX_RETRIES" ]; do
pkill -f "uvicorn" 2>/dev/null || true
cd "$SCRIPT_DIR/backend"
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 &
BACKEND_PID=$!
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
return 0
fi
echo -e "${YELLOW}⚠️ 后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
kill "$BACKEND_PID" 2>/dev/null || true
retry=$((retry + 1))
done
return 1
}
start_frontend_with_retry() {
local frontend_port="$1"
local retry=1
while [ "$retry" -le "$FRONTEND_MAX_RETRIES" ]; do
pkill -f "vite" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
cd "$SCRIPT_DIR/frontend"
nohup bun run dev --port "$frontend_port" > /tmp/planet_frontend.log 2>&1 &
FRONTEND_PID=$!
if wait_for_http "http://localhost:${frontend_port}" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL" "前端"; then
return 0
fi
echo -e "${YELLOW}⚠️ 前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
kill "$FRONTEND_PID" 2>/dev/null || true
retry=$((retry + 1))
done
return 1
}
validate_port() {
local port="$1"
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo -e "${RED}❌ 非法端口: ${port}${NC}"
exit 1
fi
}
kill_port_if_requested() {
local port="$1"
local service_name="$2"
echo -e "${YELLOW}🧹 检测 ${service_name} 端口 ${port} 占用...${NC}"
if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
fuser -k "${port}/tcp" >/dev/null 2>&1 || true
sleep 1
return 0
fi
if command -v lsof >/dev/null 2>&1; then
local pids
pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)"
if [ -n "$pids" ]; then
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
kill $pids 2>/dev/null || true
sleep 1
return 0
fi
fi
echo -e "${GREEN}✅ 端口 ${port} 未被占用${NC}"
}
parse_service_args() {
BACKEND_PORT="$DEFAULT_BACKEND_PORT"
FRONTEND_PORT="$DEFAULT_FRONTEND_PORT"
BACKEND_PORT_REQUESTED=0
FRONTEND_PORT_REQUESTED=0
while [ "$#" -gt 0 ]; do
case "$1" in
-b|--backend-port)
BACKEND_PORT_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
BACKEND_PORT="$2"
shift 2
else
shift 1
fi
;;
-f|--frontend-port)
FRONTEND_PORT_REQUESTED=1
if [ -n "$2" ] && [[ "$2" =~ ^[0-9]+$ ]]; then
FRONTEND_PORT="$2"
shift 2
else
shift 1
fi
;;
*)
echo -e "${RED}❌ 未知参数: $1${NC}"
exit 1
;;
esac
done
validate_port "$BACKEND_PORT"
validate_port "$FRONTEND_PORT"
}
cleanup_exit_containers() {
local exit_containers
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
if [ -n "$exit_containers" ]; then
echo -e "${BLUE}🗑️ 清理残留 Exit 容器...${NC}"
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
echo -e "${GREEN}✅ 残留容器已清理${NC}"
fi
}
stop_backend_service() {
pkill -f "uvicorn" 2>/dev/null || true
}
stop_frontend_service() {
pkill -f "vite" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
}
start_backend_service() {
local backend_port="$1"
local backend_port_requested="$2"
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
docker start planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis
sleep 3
if [ "$backend_port_requested" -eq 1 ]; then
kill_port_if_requested "$backend_port" "后端"
fi
echo -e "${BLUE}🔧 启动后端...${NC}"
ensure_uv_backend_deps
pkill -f "uvicorn" 2>/dev/null || true
cd "$SCRIPT_DIR/backend"
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 &
BACKEND_PID=$!
sleep 3
if ! curl -s http://localhost:8000/health > /dev/null 2>&1; then
echo -e "${RED}❌ 后端启动失败${NC}"
if ! start_backend_with_retry "$backend_port"; then
echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES}${NC}"
tail -10 /tmp/planet_backend.log
exit 1
fi
}
start_frontend_service() {
local frontend_port="$1"
local frontend_port_requested="$2"
if [ "$frontend_port_requested" -eq 1 ]; then
kill_port_if_requested "$frontend_port" "前端"
fi
echo -e "${BLUE}🌐 启动前端...${NC}"
ensure_frontend_deps
pkill -f "vite" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
cd "$SCRIPT_DIR/frontend"
nohup bun run dev --port 3000 > /tmp/planet_frontend.log 2>&1 &
if ! start_frontend_with_retry "$frontend_port"; then
echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}${NC}"
tail -10 /tmp/planet_frontend.log
exit 1
fi
}
sleep 3
create_user() {
local username
local password
local password_confirm
local email
local generated_email
local role="viewer"
local is_admin
ensure_uv_backend_deps
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
docker start planet_postgres 2>/dev/null || docker-compose up -d postgres
sleep 2
echo -e "${BLUE}👤 创建用户${NC}"
read -r -p "用户名: " username
if [ -z "$username" ]; then
echo -e "${RED}❌ 用户名不能为空${NC}"
exit 1
fi
generated_email="${username}@planet.local"
read -r -p "邮箱(留空自动使用 ${generated_email}): " email
if [ -z "$email" ]; then
email="$generated_email"
fi
read -r -s -p "密码: " password
echo ""
read -r -s -p "确认密码: " password_confirm
echo ""
if [ -z "$password" ]; then
echo -e "${RED}❌ 密码不能为空${NC}"
exit 1
fi
if [ "${#password}" -lt 8 ]; then
echo -e "${RED}❌ 密码长度不能少于 8 位${NC}"
exit 1
fi
if [ "$password" != "$password_confirm" ]; then
echo -e "${RED}❌ 两次输入的密码不一致${NC}"
exit 1
fi
read -r -p "是否为管理员? (Y/N): " is_admin
if [[ "$is_admin" =~ ^[Yy]$ ]]; then
role="super_admin"
fi
cd "$SCRIPT_DIR/backend"
PLANET_CREATEUSER_USERNAME="$username" \
PLANET_CREATEUSER_PASSWORD="$password" \
PLANET_CREATEUSER_EMAIL="$email" \
PLANET_CREATEUSER_ROLE="$role" \
PYTHONPATH="$SCRIPT_DIR/backend" \
"$SCRIPT_DIR/.venv/bin/python" - <<'PY'
import asyncio
import os
from sqlalchemy import text
from app.core.security import get_password_hash
from app.db.session import async_session_factory
from app.models.user import User
async def main():
username = os.environ["PLANET_CREATEUSER_USERNAME"].strip()
password = os.environ["PLANET_CREATEUSER_PASSWORD"]
email = os.environ["PLANET_CREATEUSER_EMAIL"].strip()
role = os.environ["PLANET_CREATEUSER_ROLE"].strip()
async with async_session_factory() as session:
result = await session.execute(
text("SELECT id FROM users WHERE username = :username OR email = :email"),
{"username": username, "email": email},
)
if result.fetchone():
raise SystemExit("用户名已存在")
user = User(
username=username,
email=email,
password_hash=get_password_hash(password),
role=role,
is_active=True,
)
session.add(user)
await session.commit()
asyncio.run(main())
PY
echo -e "${GREEN}✅ 用户创建成功${NC}"
echo " 用户名: ${username}"
echo " 角色: ${role}"
echo " 邮箱: ${email}"
}
start() {
parse_service_args "$@"
cleanup_exit_containers
echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED"
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
echo ""
echo -e "${GREEN}✅ 启动完成!${NC}"
echo " 前端: http://localhost:3000"
echo " 后端: http://localhost:8000"
echo " 前端: http://localhost:${FRONTEND_PORT}"
echo " 后端: http://localhost:${BACKEND_PORT}"
}
stop() {
echo -e "${YELLOW}🛑 停止服务...${NC}"
pkill -f "uvicorn" 2>/dev/null || true
pkill -f "vite" 2>/dev/null || true
pkill -f "bun run dev" 2>/dev/null || true
stop_backend_service
stop_frontend_service
docker stop planet_postgres planet_redis 2>/dev/null || true
echo -e "${GREEN}✅ 已停止${NC}"
}
restart() {
stop
sleep 1
start
parse_service_args "$@"
cleanup_exit_containers
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ]; then
stop
sleep 1
start
return 0
fi
echo -e "${YELLOW}🔄 按需重启服务...${NC}"
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
stop_backend_service
sleep 1
start_backend_service "$BACKEND_PORT" 1
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
stop_frontend_service
sleep 1
start_frontend_service "$FRONTEND_PORT" 1
fi
echo ""
echo -e "${GREEN}✅ 重启完成!${NC}"
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
echo " 后端: http://localhost:${BACKEND_PORT}"
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
echo " 前端: http://localhost:${FRONTEND_PORT}"
fi
}
health() {
@@ -147,13 +461,18 @@ log() {
case "$1" in
start)
start
shift
start "$@"
;;
stop)
stop
;;
restart)
restart
shift
restart "$@"
;;
createuser)
create_user
;;
health)
health
@@ -162,12 +481,13 @@ case "$1" in
log "$2"
;;
*)
echo "用法: ./planet.sh {start|stop|restart|health|log}"
echo "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
echo ""
echo "命令:"
echo " start 启动服务"
echo " start 启动服务,可选: -b <后端端口> -f <前端端口>"
echo " stop 停止服务"
echo " restart 重启服务"
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口]"
echo " createuser 交互创建用户"
echo " health 检查健康状态"
echo " log 查看日志"
echo " log -f 查看前端日志"

View File

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

Some files were not shown because too many files have changed in this diff Show More