Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36672e4c53 | |||
| 506402ce16 | |||
| 9d135bf2e1 | |||
|
|
49a9c33836 |
18
README.md
@@ -184,20 +184,14 @@
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 启动前后端服务
|
||||
./planet.sh start
|
||||
# 启动全部服务
|
||||
docker-compose up -d
|
||||
|
||||
# 仅重启后端
|
||||
./planet.sh restart -b
|
||||
# 仅启动后端
|
||||
cd backend && python -m uvicorn app.main:app --reload
|
||||
|
||||
# 仅重启前端
|
||||
./planet.sh restart -f
|
||||
|
||||
# 交互创建用户
|
||||
./planet.sh createuser
|
||||
|
||||
# 查看服务状态
|
||||
./planet.sh health
|
||||
# 仅启动前端
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
4
TODO.md
@@ -1,4 +0,0 @@
|
||||
# TODO
|
||||
|
||||
- [ ] 把 BGP 观测站和异常点的 `hover/click` 手感再磨细一点
|
||||
- [ ] 开始做 BGP 异常和海缆/区域的关联展示
|
||||
@@ -11,7 +11,6 @@ from app.api.v1 import (
|
||||
settings,
|
||||
collected_data,
|
||||
visualization,
|
||||
bgp,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -28,4 +27,3 @@ 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"])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import 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.now(UTC)
|
||||
alert.acknowledged_at = datetime.utcnow()
|
||||
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.now(UTC)
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
alert.resolution_notes = resolution
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
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()
|
||||
@@ -9,12 +9,10 @@ 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()
|
||||
|
||||
@@ -102,13 +100,11 @@ def build_search_rank_sql(search: Optional[str]) -> str:
|
||||
"""
|
||||
|
||||
|
||||
def serialize_collected_row(row, source_name_map: dict[str, str] | None = None) -> dict:
|
||||
def serialize_collected_row(row) -> dict:
|
||||
metadata = row[7]
|
||||
source = row[1]
|
||||
return {
|
||||
"id": row[0],
|
||||
"source": source,
|
||||
"source_name": source_name_map.get(source, source) if source_name_map else source,
|
||||
"source": row[1],
|
||||
"source_id": row[2],
|
||||
"data_type": row[3],
|
||||
"name": row[4],
|
||||
@@ -125,17 +121,12 @@ def serialize_collected_row(row, source_name_map: dict[str, str] | None = None)
|
||||
"rmax": get_metadata_field(metadata, "rmax"),
|
||||
"rpeak": get_metadata_field(metadata, "rpeak"),
|
||||
"power": get_metadata_field(metadata, "power"),
|
||||
"collected_at": to_iso8601_utc(row[8]),
|
||||
"reference_date": to_iso8601_utc(row[9]),
|
||||
"collected_at": row[8].isoformat() if row[8] else None,
|
||||
"reference_date": row[9].isoformat() if row[9] else None,
|
||||
"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"),
|
||||
@@ -197,11 +188,10 @@ 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], source_name_map))
|
||||
data.append(serialize_collected_row(row[:11]))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
@@ -214,38 +204,23 @@ 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, 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
|
||||
where_sql = "WHERE COALESCE(is_current, TRUE) = TRUE" if mode != "history" else ""
|
||||
|
||||
# By source and data_type
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
text("""
|
||||
SELECT source, data_type, COUNT(*) as count
|
||||
FROM collected_data
|
||||
WHERE {where_sql}
|
||||
""" + 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
|
||||
@@ -254,56 +229,27 @@ async def get_data_summary(
|
||||
data_type = row[1]
|
||||
count = row[2]
|
||||
|
||||
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
|
||||
if source not in by_source:
|
||||
by_source[source] = {}
|
||||
by_source[source][data_type] = count
|
||||
total += count
|
||||
|
||||
# Total by source
|
||||
source_totals = await db.execute(
|
||||
text(f"""
|
||||
text("""
|
||||
SELECT source, COUNT(*) as count
|
||||
FROM collected_data
|
||||
WHERE {where_sql}
|
||||
""" + 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],
|
||||
"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
|
||||
],
|
||||
"source_totals": [{"source": row[0], "count": row[1]} for row in source_rows],
|
||||
}
|
||||
|
||||
|
||||
@@ -323,13 +269,9 @@ async def get_data_sources(
|
||||
""")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
source_name_map = await get_source_name_map(db)
|
||||
|
||||
return {
|
||||
"sources": [
|
||||
{"source": row[0], "source_name": source_name_map.get(row[0], row[0])}
|
||||
for row in rows
|
||||
],
|
||||
"sources": [row[0] for row in rows],
|
||||
}
|
||||
|
||||
|
||||
@@ -392,8 +334,7 @@ async def get_collected_data(
|
||||
detail="数据不存在",
|
||||
)
|
||||
|
||||
source_name_map = await get_source_name_map(db)
|
||||
return serialize_collected_row(row, source_name_map)
|
||||
return serialize_collected_row(row)
|
||||
|
||||
|
||||
def build_where_clause(
|
||||
@@ -541,8 +482,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 "",
|
||||
to_iso8601_utc(row[8]) or "",
|
||||
to_iso8601_utc(row[9]) or "",
|
||||
row[8].isoformat() if row[8] else "",
|
||||
row[9].isoformat() if row[9] else "",
|
||||
row[10],
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Dashboard API with caching and optimizations"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,7 +13,6 @@ 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)
|
||||
@@ -112,7 +111,7 @@ async def get_stats(
|
||||
if cached_result:
|
||||
return cached_result
|
||||
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Count built-in collectors
|
||||
built_in_count = len(COLLECTOR_INFO)
|
||||
@@ -176,7 +175,7 @@ async def get_stats(
|
||||
"active_datasources": active_datasources,
|
||||
"tasks_today": tasks_today,
|
||||
"success_rate": round(success_rate, 1),
|
||||
"last_updated": to_iso8601_utc(datetime.now(UTC)),
|
||||
"last_updated": datetime.utcnow().isoformat(),
|
||||
"alerts": {
|
||||
"critical": critical_alerts,
|
||||
"warning": warning_alerts,
|
||||
@@ -231,10 +230,10 @@ async def get_summary(
|
||||
summary[module] = {
|
||||
"datasources": data["datasources"],
|
||||
"total_records": 0, # Built-in don't track this in dashboard stats
|
||||
"last_updated": to_iso8601_utc(datetime.now(UTC)),
|
||||
"last_updated": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
response = {"modules": summary, "last_updated": to_iso8601_utc(datetime.now(UTC))}
|
||||
response = {"modules": summary, "last_updated": datetime.utcnow().isoformat()}
|
||||
|
||||
cache.set(cache_key, response, expire_seconds=300)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ 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()
|
||||
|
||||
@@ -124,8 +123,8 @@ async def list_configs(
|
||||
"headers": c.headers,
|
||||
"config": c.config,
|
||||
"is_active": c.is_active,
|
||||
"created_at": to_iso8601_utc(c.created_at),
|
||||
"updated_at": to_iso8601_utc(c.updated_at),
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||
}
|
||||
for c in configs
|
||||
],
|
||||
@@ -156,8 +155,8 @@ async def get_config(
|
||||
"headers": config.headers,
|
||||
"config": config.config,
|
||||
"is_active": config.is_active,
|
||||
"created_at": to_iso8601_utc(config.created_at),
|
||||
"updated_at": to_iso8601_utc(config.updated_at),
|
||||
"created_at": config.created_at.isoformat() if config.created_at else None,
|
||||
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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
|
||||
@@ -27,12 +24,6 @@ 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:
|
||||
@@ -56,7 +47,6 @@ 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)
|
||||
)
|
||||
@@ -104,9 +94,9 @@ async def list_datasources(
|
||||
)
|
||||
data_count = data_count_result.scalar() or 0
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
collector_list.append(
|
||||
{
|
||||
@@ -120,10 +110,6 @@ 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,
|
||||
@@ -136,105 +122,6 @@ 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,
|
||||
@@ -330,19 +217,15 @@ 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(20):
|
||||
await asyncio.sleep(0.1)
|
||||
for _ in range(10):
|
||||
task_id = await get_latest_task_id_for_datasource(datasource.id)
|
||||
if task_id is not None and task_id != previous_task_id:
|
||||
if task_id is not None:
|
||||
break
|
||||
if task_id == previous_task_id:
|
||||
task_id = None
|
||||
|
||||
return {
|
||||
"status": "triggered",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
@@ -115,9 +114,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": to_iso8601_utc(datasource.last_run_at),
|
||||
"last_run_at": datasource.last_run_at.isoformat() if datasource.last_run_at else None,
|
||||
"last_status": datasource.last_status,
|
||||
"next_run_at": to_iso8601_utc(datasource.next_run_at),
|
||||
"next_run_at": datasource.next_run_at.isoformat() if datasource.next_run_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -217,5 +216,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": to_iso8601_utc(datetime.now(UTC)),
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
|
||||
@@ -62,8 +61,8 @@ async def list_tasks(
|
||||
"datasource_id": t[1],
|
||||
"datasource_name": t[2],
|
||||
"status": t[3],
|
||||
"started_at": to_iso8601_utc(t[4]),
|
||||
"completed_at": to_iso8601_utc(t[5]),
|
||||
"started_at": t[4].isoformat() if t[4] else None,
|
||||
"completed_at": t[5].isoformat() if t[5] else None,
|
||||
"records_processed": t[6],
|
||||
"error_message": t[7],
|
||||
}
|
||||
@@ -101,8 +100,8 @@ async def get_task(
|
||||
"datasource_id": task[1],
|
||||
"datasource_name": task[2],
|
||||
"status": task[3],
|
||||
"started_at": to_iso8601_utc(task[4]),
|
||||
"completed_at": to_iso8601_utc(task[5]),
|
||||
"started_at": task[4].isoformat() if task[4] else None,
|
||||
"completed_at": task[5].isoformat() if task[5] else None,
|
||||
"records_processed": task[6],
|
||||
"error_message": task[7],
|
||||
}
|
||||
@@ -148,8 +147,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.now(UTC),
|
||||
"completed_at": datetime.now(UTC),
|
||||
"started_at": datetime.utcnow(),
|
||||
"completed_at": datetime.utcnow(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -4,20 +4,16 @@ Unified API for all visualization data sources.
|
||||
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
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()
|
||||
|
||||
@@ -159,20 +155,6 @@ 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",
|
||||
@@ -192,8 +174,6 @@ 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",
|
||||
},
|
||||
}
|
||||
@@ -276,131 +256,6 @@ 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 ==============
|
||||
|
||||
|
||||
@@ -528,11 +383,7 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.get("/geo/satellites")
|
||||
async def get_satellites_geojson(
|
||||
limit: Optional[int] = Query(
|
||||
None,
|
||||
ge=1,
|
||||
description="Maximum number of satellites to return. Omit for no limit.",
|
||||
),
|
||||
limit: int = 10000,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取卫星 TLE GeoJSON 数据"""
|
||||
@@ -541,9 +392,8 @@ 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()
|
||||
|
||||
@@ -607,31 +457,6 @@ 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)):
|
||||
"""获取所有可视化数据的统一端点
|
||||
@@ -702,7 +527,7 @@ async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": to_iso8601_utc(datetime.now(UTC)),
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"version": "1.0",
|
||||
"data": {
|
||||
"satellites": satellites,
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from datetime import 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__)
|
||||
@@ -60,7 +59,6 @@ async def websocket_endpoint(
|
||||
"ixp_nodes",
|
||||
"alerts",
|
||||
"dashboard",
|
||||
"datasource_tasks",
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -74,7 +72,7 @@ async def websocket_endpoint(
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"data": {"action": "pong", "timestamp": to_iso8601_utc(datetime.now(UTC))},
|
||||
"data": {"action": "pong", "timestamp": datetime.utcnow().isoformat()},
|
||||
}
|
||||
)
|
||||
elif data.get("type") == "subscribe":
|
||||
|
||||
@@ -6,16 +6,9 @@ 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 = (
|
||||
os.getenv("APP_VERSION")
|
||||
or (VERSION_FILE.read_text(encoding="utf-8").strip() if VERSION_FILE.exists() else "0.19.0")
|
||||
)
|
||||
VERSION: str = "1.0.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
|
||||
@@ -23,8 +23,6 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,9 +37,3 @@ 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"
|
||||
|
||||
@@ -120,20 +120,6 @@ 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()}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"""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
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import 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.now(UTC) + expires_delta
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
elif settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + 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.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire})
|
||||
to_encode.update({"type": "refresh"})
|
||||
if "sub" in to_encode:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""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")
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Data broadcaster for WebSocket connections"""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
|
||||
|
||||
@@ -23,7 +22,7 @@ class DataBroadcaster:
|
||||
"active_datasources": 8,
|
||||
"tasks_today": 45,
|
||||
"success_rate": 97.8,
|
||||
"last_updated": to_iso8601_utc(datetime.now(UTC)),
|
||||
"last_updated": datetime.utcnow().isoformat(),
|
||||
"alerts": {"critical": 0, "warning": 2, "info": 5},
|
||||
}
|
||||
|
||||
@@ -36,7 +35,7 @@ class DataBroadcaster:
|
||||
{
|
||||
"type": "data_frame",
|
||||
"channel": "dashboard",
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"payload": {"stats": stats},
|
||||
},
|
||||
channel="dashboard",
|
||||
@@ -50,7 +49,7 @@ class DataBroadcaster:
|
||||
await manager.broadcast(
|
||||
{
|
||||
"type": "alert_notification",
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"data": {"alert": alert},
|
||||
}
|
||||
)
|
||||
@@ -61,7 +60,7 @@ class DataBroadcaster:
|
||||
{
|
||||
"type": "data_frame",
|
||||
"channel": "gpu_clusters",
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"payload": data,
|
||||
}
|
||||
)
|
||||
@@ -72,24 +71,12 @@ class DataBroadcaster:
|
||||
{
|
||||
"type": "data_frame",
|
||||
"channel": channel,
|
||||
"timestamp": to_iso8601_utc(datetime.now(UTC)),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"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:
|
||||
|
||||
@@ -60,28 +60,6 @@ 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
|
||||
@@ -90,7 +68,6 @@ 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
|
||||
|
||||
@@ -148,4 +125,3 @@ async def init_db():
|
||||
|
||||
async with async_session_factory() as session:
|
||||
await seed_default_datasources(session)
|
||||
await ensure_default_admin_user(session)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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__ = [
|
||||
@@ -19,5 +18,4 @@ __all__ = [
|
||||
"Alert",
|
||||
"AlertSeverity",
|
||||
"AlertStatus",
|
||||
"BGPAnomaly",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
|
||||
|
||||
@@ -51,8 +50,8 @@ class Alert(Base):
|
||||
"acknowledged_by": self.acknowledged_by,
|
||||
"resolved_by": self.resolved_by,
|
||||
"resolution_notes": self.resolution_notes,
|
||||
"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),
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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),
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
|
||||
|
||||
@@ -75,11 +74,15 @@ class CollectedData(Base):
|
||||
"value": get_record_field(self, "value"),
|
||||
"unit": get_record_field(self, "unit"),
|
||||
"metadata": self.extra_data,
|
||||
"collected_at": to_iso8601_utc(self.collected_at),
|
||||
"reference_date": to_iso8601_utc(self.reference_date),
|
||||
"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,
|
||||
"is_current": self.is_current,
|
||||
"previous_record_id": self.previous_record_id,
|
||||
"change_type": self.change_type,
|
||||
"change_summary": self.change_summary,
|
||||
"deleted_at": to_iso8601_utc(self.deleted_at),
|
||||
"deleted_at": self.deleted_at.isoformat() if self.deleted_at is not None else None,
|
||||
}
|
||||
|
||||
@@ -30,8 +30,6 @@ 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())
|
||||
@@ -53,5 +51,3 @@ collector_registry.register(ArcGISLandingPointCollector())
|
||||
collector_registry.register(ArcGISCableLandingRelationCollector())
|
||||
collector_registry.register(SpaceTrackTLECollector())
|
||||
collector_registry.register(CelesTrakTLECollector())
|
||||
collector_registry.register(RISLiveCollector())
|
||||
collector_registry.register(BGPStreamBackfillCollector())
|
||||
|
||||
@@ -5,7 +5,7 @@ Collects submarine cable data from ArcGIS GeoJSON API.
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
from datetime import 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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
}
|
||||
result.append(entry)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
from datetime import 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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
}
|
||||
result.append(entry)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
@@ -143,7 +143,7 @@ class ArcGISCableLandingRelationCollector(BaseCollector):
|
||||
"facility": facility,
|
||||
"status": status,
|
||||
},
|
||||
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
}
|
||||
result.append(entry)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -10,8 +10,6 @@ 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):
|
||||
@@ -22,14 +20,12 @@ 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
|
||||
@@ -37,53 +33,18 @@ class BaseCollector(ABC):
|
||||
config = get_data_sources_config()
|
||||
self._resolved_url = await config.get_url(self.name, db)
|
||||
|
||||
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):
|
||||
def update_progress(self, records_processed: int):
|
||||
"""Update task progress - call this during data processing"""
|
||||
if self._current_task and self._db_session:
|
||||
if self._current_task and self._db_session and self._current_task.total_records > 0:
|
||||
self._current_task.records_processed = records_processed
|
||||
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]]:
|
||||
@@ -172,7 +133,7 @@ class BaseCollector(ABC):
|
||||
from app.models.task import CollectionTask
|
||||
from app.models.data_snapshot import DataSnapshot
|
||||
|
||||
start_time = datetime.now(UTC)
|
||||
start_time = datetime.utcnow()
|
||||
datasource_id = getattr(self, "_datasource_id", 1)
|
||||
snapshot_id: Optional[int] = None
|
||||
|
||||
@@ -191,20 +152,14 @@ 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)
|
||||
@@ -217,35 +172,33 @@ class BaseCollector(ABC):
|
||||
task.phase = "completed"
|
||||
task.records_processed = records_count
|
||||
task.progress = 100.0
|
||||
task.completed_at = datetime.now(UTC)
|
||||
task.completed_at = datetime.utcnow()
|
||||
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.now(UTC) - start_time).total_seconds(),
|
||||
"execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(),
|
||||
}
|
||||
except Exception as e:
|
||||
task.status = "failed"
|
||||
task.phase = "failed"
|
||||
task.error_message = str(e)
|
||||
task.completed_at = datetime.now(UTC)
|
||||
task.completed_at = datetime.utcnow()
|
||||
if snapshot_id is not None:
|
||||
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||
if snapshot:
|
||||
snapshot.status = "failed"
|
||||
snapshot.completed_at = datetime.now(UTC)
|
||||
snapshot.completed_at = datetime.utcnow()
|
||||
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.now(UTC) - start_time).total_seconds(),
|
||||
"execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(),
|
||||
}
|
||||
|
||||
async def _save_data(
|
||||
@@ -266,11 +219,11 @@ class BaseCollector(ABC):
|
||||
snapshot.record_count = 0
|
||||
snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0}
|
||||
snapshot.status = "success"
|
||||
snapshot.completed_at = datetime.now(UTC)
|
||||
snapshot.completed_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return 0
|
||||
|
||||
collected_at = datetime.now(UTC)
|
||||
collected_at = datetime.utcnow()
|
||||
records_added = 0
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
@@ -376,7 +329,8 @@ class BaseCollector(ABC):
|
||||
records_added += 1
|
||||
|
||||
if i % 100 == 0:
|
||||
await self.update_progress(i + 1, commit=True)
|
||||
self.update_progress(i + 1)
|
||||
await db.commit()
|
||||
|
||||
if snapshot_id is not None:
|
||||
deleted_keys = previous_current_keys - seen_entity_keys
|
||||
@@ -396,7 +350,7 @@ class BaseCollector(ABC):
|
||||
if snapshot:
|
||||
snapshot.record_count = records_added
|
||||
snapshot.status = "success"
|
||||
snapshot.completed_at = datetime.now(UTC)
|
||||
snapshot.completed_at = datetime.utcnow()
|
||||
snapshot.summary = {
|
||||
"created": created_count,
|
||||
"updated": updated_count,
|
||||
@@ -405,7 +359,7 @@ class BaseCollector(ABC):
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
await self.update_progress(len(data), force=True)
|
||||
self.update_progress(len(data))
|
||||
return records_added
|
||||
|
||||
async def save(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int:
|
||||
@@ -452,8 +406,8 @@ async def log_task(
|
||||
status=status,
|
||||
records_processed=records_processed,
|
||||
error_message=error_message,
|
||||
started_at=datetime.now(UTC),
|
||||
completed_at=datetime.now(UTC),
|
||||
started_at=datetime.utcnow(),
|
||||
completed_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
"""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
|
||||
@@ -1,120 +0,0 @@
|
||||
"""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()
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
|
||||
@@ -62,17 +61,6 @@ 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"),
|
||||
@@ -92,10 +80,6 @@ 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 UTC, datetime
|
||||
from datetime import 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.now(UTC).isoformat(),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).isoformat()),
|
||||
"reference_date": item.get("datetime", datetime.utcnow().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.now(UTC).isoformat(),
|
||||
"reference_date": datetime.utcnow().isoformat(),
|
||||
}
|
||||
data.append(entry)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
|
||||
@@ -6,7 +6,7 @@ https://epoch.ai/data/gpu-clusters
|
||||
|
||||
import re
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime
|
||||
from bs4 import BeautifulSoup
|
||||
import httpx
|
||||
|
||||
@@ -64,7 +64,7 @@ class EpochAIGPUCollector(BaseCollector):
|
||||
"metadata": {
|
||||
"raw_data": perf_cell,
|
||||
},
|
||||
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ Collects landing point data from FAO CSV API.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
from datetime import 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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
}
|
||||
result.append(entry)
|
||||
except (ValueError, IndexError):
|
||||
|
||||
@@ -7,7 +7,7 @@ https://huggingface.co/spaces
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
from datetime import 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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
}
|
||||
data.append(entry)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
|
||||
@@ -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 UTC, datetime
|
||||
from datetime import 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.now(UTC).isoformat(),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).isoformat(),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).isoformat(),
|
||||
"reference_date": datetime.utcnow().isoformat(),
|
||||
}
|
||||
data.append(entry)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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()
|
||||
@@ -10,7 +10,6 @@ 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):
|
||||
@@ -170,21 +169,9 @@ 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"),
|
||||
"reference_date": item.get("EPOCH", ""),
|
||||
"metadata": {
|
||||
"norad_cat_id": item.get("NORAD_CAT_ID"),
|
||||
"international_designator": item.get("INTL_DESIGNATOR"),
|
||||
"epoch": item.get("EPOCH"),
|
||||
@@ -201,10 +188,6 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
"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
|
||||
|
||||
@@ -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 UTC, datetime
|
||||
from datetime import 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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
},
|
||||
{
|
||||
"source_id": "telegeo_sample_2",
|
||||
@@ -147,7 +147,7 @@ class TeleGeographyCableCollector(BaseCollector):
|
||||
"owner": "Alibaba, NEC",
|
||||
"status": "planned",
|
||||
},
|
||||
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -187,7 +187,7 @@ class TeleGeographyLandingPointCollector(BaseCollector):
|
||||
"cable_count": len(item.get("cables", [])),
|
||||
"url": item.get("url"),
|
||||
},
|
||||
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -258,7 +258,7 @@ class TeleGeographyCableSystemCollector(BaseCollector):
|
||||
"investment": item.get("investment"),
|
||||
"url": item.get("url"),
|
||||
},
|
||||
"reference_date": datetime.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().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.now(UTC).strftime("%Y-%m-%d"),
|
||||
"reference_date": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -80,12 +79,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.now(UTC)
|
||||
datasource.last_run_at = datetime.utcnow()
|
||||
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.now(UTC)
|
||||
datasource.last_run_at = datetime.utcnow()
|
||||
datasource.last_status = "failed"
|
||||
await db.commit()
|
||||
logger.exception("Collector %s failed: %s", collector_name, exc)
|
||||
@@ -93,7 +92,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.now(UTC) - timedelta(hours=max_age_hours)
|
||||
cutoff = datetime.utcnow() - timedelta(hours=max_age_hours)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(
|
||||
@@ -108,7 +107,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.now(UTC)
|
||||
task.completed_at = datetime.utcnow()
|
||||
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
|
||||
@@ -168,7 +167,7 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]:
|
||||
{
|
||||
"id": job.id,
|
||||
"name": job.name,
|
||||
"next_run_time": to_iso8601_utc(job.next_run_time),
|
||||
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
"trigger": str(job.trigger),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
"""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]
|
||||
@@ -1,269 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,487 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,210 +0,0 @@
|
||||
# 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 全量清理
|
||||
- 错误状态隔离
|
||||
|
||||
这个阶段不追求“更炫”,先追求“更稳”。稳定下来之后,再进入性能和架构层的优化。
|
||||
@@ -1,84 +0,0 @@
|
||||
# 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
|
||||
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.21.7",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "planet-frontend",
|
||||
"version": "0.21.7",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.12.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.21.8",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 480 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 310 B |
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 871 B |
@@ -1,10 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 927 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 278 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 163 B |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 631 B |
@@ -1,8 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 561 B |
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 858 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 449 B |
@@ -1,7 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 501 B |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 446 B |
@@ -13,23 +13,6 @@ 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;
|
||||
@@ -40,103 +23,85 @@ body {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Bottom Dock */
|
||||
/* Right Toolbar Group */
|
||||
#right-toolbar-group {
|
||||
position: absolute;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 20px;
|
||||
right: 290px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
#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 > * {
|
||||
/* Zoom Toolbar - Right side, vertical */
|
||||
#zoom-toolbar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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);
|
||||
}
|
||||
|
||||
#loading {
|
||||
@@ -220,28 +185,16 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.status-message {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -18px);
|
||||
right: 260px;
|
||||
background-color: rgba(10, 10, 30, 0.85);
|
||||
border-radius: 10px;
|
||||
padding: 10px 15px;
|
||||
z-index: 210;
|
||||
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);
|
||||
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 {
|
||||
@@ -274,172 +227,71 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Floating toolbar dock */
|
||||
/* Control Toolbar - Stellarium/Star Walk style */
|
||||
#control-toolbar {
|
||||
position: relative;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
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;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.toolbar-items {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
@@ -447,309 +299,38 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
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;
|
||||
overflow: visible;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.toolbar-btn:not(.liquid-glass-surface)::after {
|
||||
content: 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 .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 {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.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.active {
|
||||
background: rgba(77, 184, 255, 0.4);
|
||||
box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
|
||||
}
|
||||
|
||||
.toolbar-btn .tooltip {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
bottom: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 30, 0.95);
|
||||
@@ -766,12 +347,10 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover .tooltip,
|
||||
.floating-popover-group:hover > .toolbar-btn .tooltip,
|
||||
.floating-popover-group:focus-within > .toolbar-btn .tooltip {
|
||||
.toolbar-btn:hover .tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
bottom: 58px;
|
||||
bottom: 52px;
|
||||
}
|
||||
|
||||
.toolbar-btn .tooltip::after {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
/* coordinates-display */
|
||||
|
||||
#coordinates-display {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
border-radius: 18px;
|
||||
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;
|
||||
min-width: 180px;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
#coordinates-display .coord-item {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
/* earth-stats */
|
||||
|
||||
#earth-stats {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
border-radius: 18px;
|
||||
background-color: rgba(10, 10, 30, 0.85);
|
||||
border-radius: 10px;
|
||||
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 {
|
||||
@@ -26,13 +31,18 @@
|
||||
}
|
||||
|
||||
#satellite-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 290px;
|
||||
border-radius: 18px;
|
||||
background-color: rgba(10, 10, 30, 0.9);
|
||||
border-radius: 10px;
|
||||
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 {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/* info-panel */
|
||||
|
||||
#info-panel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
border-radius: 18px;
|
||||
background-color: rgba(10, 10, 30, 0.85);
|
||||
border-radius: 10px;
|
||||
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 {
|
||||
@@ -14,34 +19,14 @@
|
||||
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: 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;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
#info-panel .cable-info {
|
||||
@@ -174,14 +159,8 @@
|
||||
/* Info Card - Unified details panel (inside info-panel) */
|
||||
.info-card {
|
||||
margin-top: 15px;
|
||||
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);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -195,7 +174,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(77, 184, 255, 0.06));
|
||||
background: rgba(77, 184, 255, 0.1);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -210,35 +189,16 @@
|
||||
color: #4db8ff;
|
||||
}
|
||||
|
||||
#info-card-content {
|
||||
.info-card-content {
|
||||
padding: 10px 12px;
|
||||
max-height: 40vh;
|
||||
max-height: 200px;
|
||||
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;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -249,12 +209,6 @@
|
||||
.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 {
|
||||
|
||||
@@ -1,59 +1,28 @@
|
||||
/* legend */
|
||||
|
||||
#legend {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
border-radius: 18px;
|
||||
background-color: rgba(10, 10, 30, 0.85);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
width: 220px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#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;
|
||||
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-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#legend .legend-color {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
0 0 12px rgba(77, 184, 255, 0.18);
|
||||
}
|
||||
|
||||
@@ -18,25 +18,12 @@
|
||||
<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">
|
||||
<span class="subtitle-main">现实层宇宙全息感知系统</span>
|
||||
<span class="subtitle-meta">卫星 · 海底光缆 · 算力基础设施</span>
|
||||
</div>
|
||||
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
|
||||
|
||||
<div id="info-card" class="info-card" style="display: none;">
|
||||
<div class="info-card-header">
|
||||
@@ -50,98 +37,23 @@
|
||||
</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="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>
|
||||
<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>
|
||||
</div>
|
||||
<button id="toolbar-toggle" class="toolbar-btn" title="展开/收起工具栏"><span class="toggle-arrow">◀</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -160,8 +72,7 @@
|
||||
</div>
|
||||
|
||||
<div id="legend">
|
||||
<h3 class="legend-title">线缆图例</h3>
|
||||
<div class="legend-list">
|
||||
<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>
|
||||
@@ -179,7 +90,6 @@
|
||||
<span>其他电缆</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="earth-stats">
|
||||
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">地球信息</h3>
|
||||
@@ -203,10 +113,6 @@
|
||||
<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>
|
||||
@@ -219,8 +125,8 @@
|
||||
|
||||
<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;">同步卫星、海底光缆、登陆点与BGP异常数据</div>
|
||||
<div>正在加载3D地球和电缆数据...</div>
|
||||
<div style="font-size:0.9rem; margin-top:10px; color:#aaa;">使用8K高分辨率卫星纹理 | 大陆轮廓更清晰</div>
|
||||
</div>
|
||||
<div id="status-message" class="status-message" style="display: none;"></div>
|
||||
<div id="tooltip" class="tooltip"></div>
|
||||
|
||||
@@ -1,787 +0,0 @@
|
||||
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: "低危异常" },
|
||||
];
|
||||
}
|
||||
@@ -1,119 +1,65 @@
|
||||
// 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 { setLegendItems, setLegendMode } from "./legend.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';
|
||||
|
||||
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);
|
||||
}
|
||||
if (typeof properties.color === "number") {
|
||||
} else if (typeof properties.color === 'number') {
|
||||
return properties.color;
|
||||
}
|
||||
}
|
||||
|
||||
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"];
|
||||
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'];
|
||||
}
|
||||
|
||||
return CABLE_COLORS.default;
|
||||
}
|
||||
|
||||
function createCableLine(points, color, properties) {
|
||||
function createCableLine(points, color, properties, earthObj) {
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
|
||||
lineGeometry.computeBoundingSphere();
|
||||
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color,
|
||||
color: 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 ||
|
||||
properties.name ||
|
||||
Math.random().toString(36);
|
||||
const cableId = properties.cable_id || properties.id || properties.Name || Math.random().toString(36);
|
||||
cableLine.userData = {
|
||||
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(),
|
||||
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
|
||||
};
|
||||
cableLine.renderOrder = 1;
|
||||
|
||||
@@ -125,27 +71,15 @@ function createCableLine(points, color, properties) {
|
||||
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 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 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)));
|
||||
|
||||
@@ -173,56 +107,54 @@ function calculateGreatCirclePoints(
|
||||
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 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 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;
|
||||
|
||||
points.push(latLonToVector3(lat, lon, radius));
|
||||
const point = latLonToVector3(lat, lon, radius);
|
||||
points.push(point);
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log("正在加载电缆数据...");
|
||||
showStatusMessage("正在加载电缆数据...", "warning");
|
||||
try {
|
||||
console.log('正在加载电缆数据...');
|
||||
showStatusMessage('正在加载电缆数据...', 'warning');
|
||||
|
||||
const response = await fetch(PATHS.cablesApi);
|
||||
if (!response.ok) {
|
||||
throw new Error(`电缆接口返回 HTTP ${response.status}`);
|
||||
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 格式");
|
||||
throw new Error('无效的GeoJSON格式');
|
||||
}
|
||||
|
||||
clearCableLines(earthObj);
|
||||
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;
|
||||
@@ -231,8 +163,9 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
if (!geometry || !geometry.coordinates) continue;
|
||||
|
||||
const color = getCableColor(properties);
|
||||
console.log('电缆 properties:', JSON.stringify(properties));
|
||||
|
||||
if (geometry.type === "MultiLineString") {
|
||||
if (geometry.type === 'MultiLineString') {
|
||||
for (const lineCoords of geometry.coordinates) {
|
||||
if (!lineCoords || lineCoords.length < 2) continue;
|
||||
|
||||
@@ -243,218 +176,164 @@ export async function loadGeoJSONFromPath(scene, earthObj) {
|
||||
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)));
|
||||
const segment = calculateGreatCirclePoints(lat1, lon1, lat2, lon2, 100.2, 50);
|
||||
if (i === 0) {
|
||||
points.push(...segment);
|
||||
} else {
|
||||
points.push(...segment.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
const line = createCableLine(points, color, properties);
|
||||
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") {
|
||||
}
|
||||
} else if (geometry.type === 'LineString') {
|
||||
const allCoords = geometry.coordinates;
|
||||
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)));
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
const line = createCableLine(points, color, properties);
|
||||
if (points.length >= 2) {
|
||||
const line = createCableLine(points, color, properties, earthObj);
|
||||
if (line) {
|
||||
cableLines.push(line);
|
||||
earthObj.add(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 卫星图",
|
||||
textureQuality: '8K 卫星图'
|
||||
});
|
||||
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, "success");
|
||||
return cableLines.length;
|
||||
showStatusMessage(`成功加载 ${cableLines.length} 条电缆`, 'success');
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载电缆数据失败:', error);
|
||||
showStatusMessage('加载电缆数据失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLandingPoints(scene, earthObj) {
|
||||
console.log("正在加载登陆点数据...");
|
||||
try {
|
||||
console.log('正在加载登陆点数据...');
|
||||
|
||||
const response = await fetch(PATHS.landingPointsApi);
|
||||
if (!response.ok) {
|
||||
throw new Error(`登陆点接口返回 HTTP ${response.status}`);
|
||||
console.error('HTTP错误:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.features || !Array.isArray(data.features)) {
|
||||
throw new Error("无效的登陆点 GeoJSON 格式");
|
||||
console.error('无效的GeoJSON格式');
|
||||
return;
|
||||
}
|
||||
|
||||
clearLandingPoints(earthObj);
|
||||
|
||||
const sphereGeometry = new THREE.SphereGeometry(0.4, 16, 16);
|
||||
landingPoints = [];
|
||||
let validCount = 0;
|
||||
|
||||
try {
|
||||
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" ||
|
||||
Number.isNaN(lon) ||
|
||||
Number.isNaN(lat) ||
|
||||
Math.abs(lat) > 90 ||
|
||||
Math.abs(lon) > 180
|
||||
) {
|
||||
if (typeof lon !== 'number' || typeof lat !== 'number' ||
|
||||
isNaN(lon) || isNaN(lat) ||
|
||||
Math.abs(lat) > 90 || Math.abs(lon) > 180) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const position = latLonToVector3(lat, lon, CONFIG.earthRadius + 0.1);
|
||||
if (
|
||||
Number.isNaN(position.x) ||
|
||||
Number.isNaN(position.y) ||
|
||||
Number.isNaN(position.z)
|
||||
) {
|
||||
const position = latLonToVector3(lat, lon, 100.1);
|
||||
|
||||
if (isNaN(position.x) || isNaN(position.y) || isNaN(position.z)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sphere = new THREE.Mesh(
|
||||
sphereGeometry.clone(),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xffaa00,
|
||||
emissive: 0x442200,
|
||||
emissiveIntensity: 0.5,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
}),
|
||||
);
|
||||
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial.clone());
|
||||
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++;
|
||||
}
|
||||
} finally {
|
||||
sphereGeometry.dispose();
|
||||
|
||||
console.log(`成功创建 ${validCount} 个登陆点标记`);
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, 'success');
|
||||
|
||||
const lpCountEl = document.getElementById('landing-point-count');
|
||||
if (lpCountEl) {
|
||||
lpCountEl.textContent = validCount + '个';
|
||||
}
|
||||
|
||||
const landingPointCountEl = document.getElementById("landing-point-count");
|
||||
if (landingPointCountEl) {
|
||||
landingPointCountEl.textContent = validCount + "个";
|
||||
} catch (error) {
|
||||
console.error('加载登陆点数据失败:', error);
|
||||
}
|
||||
|
||||
showStatusMessage(`成功加载 ${validCount} 个登陆点`, "success");
|
||||
return validCount;
|
||||
}
|
||||
|
||||
export function handleCableClick(cable) {
|
||||
lockedCable = cable;
|
||||
setLegendItems("cables", getCableLegendItems());
|
||||
|
||||
const data = cable.userData;
|
||||
setLegendMode("cables");
|
||||
showInfoCard("cable", {
|
||||
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) || [];
|
||||
}
|
||||
@@ -463,6 +342,8 @@ export function getLandingPoints() {
|
||||
return landingPoints;
|
||||
}
|
||||
|
||||
const cableStates = new Map();
|
||||
|
||||
export function getCableState(cableId) {
|
||||
return cableStates.get(cableId) || CABLE_STATE.NORMAL;
|
||||
}
|
||||
@@ -484,9 +365,7 @@ 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() {
|
||||
@@ -497,9 +376,8 @@ 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);
|
||||
@@ -510,7 +388,8 @@ export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
|
||||
} else {
|
||||
const r = 255 * brightness;
|
||||
const g = 170 * brightness;
|
||||
lp.material.color.setRGB(r / 255, g / 255, 0);
|
||||
const b = 0 * brightness;
|
||||
lp.material.color.setRGB(r / 255, g / 255, b / 255);
|
||||
lp.material.emissive.setHex(0x000000);
|
||||
lp.material.emissiveIntensity = 0;
|
||||
lp.material.opacity = 0.3;
|
||||
@@ -520,7 +399,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;
|
||||
@@ -531,10 +410,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ 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',
|
||||
};
|
||||
@@ -56,7 +54,7 @@ export const CABLE_STATE = {
|
||||
};
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
maxCount: -1,
|
||||
maxCount: 5000,
|
||||
trailLength: 10,
|
||||
dotSize: 4,
|
||||
ringSize: 0.07,
|
||||
@@ -71,49 +69,6 @@ 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
|
||||
|
||||
415
frontend/public/earth/js/controls.js
vendored
@@ -1,55 +1,25 @@
|
||||
// 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,
|
||||
setCablesEnabled,
|
||||
setSatellitesEnabled,
|
||||
} from "./main.js";
|
||||
import {
|
||||
toggleTrails,
|
||||
getShowSatellites,
|
||||
getSatelliteCount,
|
||||
} from "./satellites.js";
|
||||
import { getShowCables } from "./cables.js";
|
||||
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
|
||||
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();
|
||||
setupLiquidGlassInteractions();
|
||||
}
|
||||
|
||||
function setupZoomControls(camera) {
|
||||
@@ -65,8 +35,7 @@ function setupZoomControls(camera) {
|
||||
|
||||
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;
|
||||
@@ -88,7 +57,7 @@ function setupZoomControls(camera) {
|
||||
|
||||
function startContinuousZoom(direction) {
|
||||
doContinuousZoom(direction);
|
||||
zoomInterval = window.setInterval(() => {
|
||||
zoomInterval = setInterval(() => {
|
||||
doContinuousZoom(direction);
|
||||
}, LONG_PRESS_TICK);
|
||||
}
|
||||
@@ -107,7 +76,7 @@ function setupZoomControls(camera) {
|
||||
function handleMouseDown(direction) {
|
||||
startTime = Date.now();
|
||||
stopZoom();
|
||||
holdTimeout = window.setTimeout(() => {
|
||||
holdTimeout = setTimeout(() => {
|
||||
startContinuousZoom(direction);
|
||||
}, HOLD_THRESHOLD);
|
||||
}
|
||||
@@ -120,61 +89,39 @@ function setupZoomControls(camera) {
|
||||
}
|
||||
}
|
||||
|
||||
cleanupFns.push(stopZoom);
|
||||
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));
|
||||
|
||||
const zoomIn = document.getElementById("zoom-in");
|
||||
const zoomOut = document.getElementById("zoom-out");
|
||||
const zoomValue = document.getElementById("zoom-value");
|
||||
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));
|
||||
|
||||
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", () => {
|
||||
document.getElementById('zoom-value').addEventListener('click', function() {
|
||||
const startZoomVal = zoomLevel;
|
||||
const targetZoom = 1.0;
|
||||
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
|
||||
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
|
||||
|
||||
animateValue(
|
||||
0,
|
||||
1,
|
||||
600,
|
||||
(progress) => {
|
||||
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;
|
||||
const distance = startDistance + (targetDistance - startDistance) * ease;
|
||||
updateZoomDisplay(zoomLevel, distance.toFixed(0));
|
||||
},
|
||||
() => {
|
||||
}, () => {
|
||||
zoomLevel = 1.0;
|
||||
showStatusMessage("缩放已重置到100%", "info");
|
||||
},
|
||||
);
|
||||
showStatusMessage('缩放已重置到100%', 'info');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupWheelZoom(camera, renderer) {
|
||||
bindListener(
|
||||
renderer?.domElement,
|
||||
"wheel",
|
||||
(e) => {
|
||||
renderer.domElement.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) {
|
||||
zoomLevel = Math.min(zoomLevel + 0.1, CONFIG.maxZoom);
|
||||
@@ -182,9 +129,7 @@ function setupWheelZoom(camera, renderer) {
|
||||
zoomLevel = Math.max(zoomLevel - 0.1, CONFIG.minZoom);
|
||||
}
|
||||
applyZoom(camera);
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
function applyZoom(camera) {
|
||||
@@ -218,21 +163,16 @@ 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) => {
|
||||
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;
|
||||
@@ -240,256 +180,96 @@ export function resetView(camera) {
|
||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
},
|
||||
() => {
|
||||
}, () => {
|
||||
zoomLevel = 1.0;
|
||||
showStatusMessage("视角已重置", "info");
|
||||
},
|
||||
);
|
||||
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,
|
||||
);
|
||||
animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon);
|
||||
}
|
||||
|
||||
clearLockedObject();
|
||||
if (typeof window.clearLockedCable === 'function') {
|
||||
window.clearLockedCable();
|
||||
}
|
||||
}
|
||||
|
||||
function setupRotateControls(camera) {
|
||||
const rotateBtn = document.getElementById("rotate-toggle");
|
||||
const resetViewBtn = document.getElementById("reset-view");
|
||||
function setupRotateControls(camera, earth) {
|
||||
const rotateBtn = document.getElementById('rotate-toggle');
|
||||
|
||||
bindListener(rotateBtn, "click", () => {
|
||||
rotateBtn.addEventListener('click', function() {
|
||||
const isRotating = toggleAutoRotate();
|
||||
showStatusMessage(isRotating ? "自动旋转已开启" : "自动旋转已暂停", "info");
|
||||
showStatusMessage(isRotating ? '自动旋转已开启' : '自动旋转已暂停', 'info');
|
||||
});
|
||||
|
||||
updateRotateUI();
|
||||
|
||||
bindListener(resetViewBtn, "click", () => {
|
||||
document.getElementById('reset-view').addEventListener('click', function() {
|
||||
resetView(camera);
|
||||
});
|
||||
}
|
||||
|
||||
function setupTerrainControls() {
|
||||
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 () {
|
||||
document.getElementById('toggle-terrain').addEventListener('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");
|
||||
this.classList.toggle('active', showTerrain);
|
||||
this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
|
||||
document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
|
||||
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
|
||||
});
|
||||
|
||||
bindListener(satellitesBtn, "click", async function () {
|
||||
document.getElementById('toggle-satellites').addEventListener('click', function() {
|
||||
const showSats = !getShowSatellites();
|
||||
if (!showSats) {
|
||||
clearLockedObject();
|
||||
}
|
||||
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);
|
||||
}
|
||||
toggleSatellites(showSats);
|
||||
this.classList.toggle('active', showSats);
|
||||
this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星';
|
||||
document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗';
|
||||
showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info');
|
||||
});
|
||||
|
||||
bindListener(bgpBtn, "click", function () {
|
||||
const showNextBGP = !getShowBGP();
|
||||
if (!showNextBGP) {
|
||||
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) {
|
||||
clearLockedObject();
|
||||
}
|
||||
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");
|
||||
toggleCables(showCables);
|
||||
this.classList.toggle('active', showCables);
|
||||
this.querySelector('.tooltip').textContent = showCables ? '隐藏线缆' : '显示线缆';
|
||||
showStatusMessage(showCables ? '线缆已显示' : '线缆已隐藏', '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", async function () {
|
||||
const showNextCables = !getShowCables();
|
||||
if (!showNextCables) {
|
||||
clearLockedObject();
|
||||
}
|
||||
try {
|
||||
await setCablesEnabled(showNextCables);
|
||||
} catch (error) {
|
||||
console.error("切换线缆显示失败:", error);
|
||||
}
|
||||
});
|
||||
|
||||
bindListener(reloadBtn, "click", async () => {
|
||||
document.getElementById('reload-data').addEventListener('click', async () => {
|
||||
await reloadData();
|
||||
showStatusMessage('数据已重新加载', 'success');
|
||||
});
|
||||
|
||||
bindListener(zoomTrigger, "click", (event) => {
|
||||
event.stopPropagation();
|
||||
infoGroup?.classList.remove("open");
|
||||
zoomGroup?.classList.toggle("open");
|
||||
const toolbarToggle = document.getElementById('toolbar-toggle');
|
||||
const toolbar = document.getElementById('control-toolbar');
|
||||
if (toolbarToggle && toolbar) {
|
||||
toolbarToggle.addEventListener('click', () => {
|
||||
toolbar.classList.toggle('collapsed');
|
||||
});
|
||||
|
||||
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() {
|
||||
@@ -497,12 +277,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.classList.toggle("is-stopped", !autoRotate);
|
||||
const tooltip = btn.querySelector(".tooltip");
|
||||
if (tooltip) tooltip.textContent = autoRotate ? "暂停旋转" : "开始旋转";
|
||||
btn.classList.toggle('active', autoRotate);
|
||||
btn.innerHTML = autoRotate ? '⏸️' : '▶️';
|
||||
const tooltip = btn.querySelector('.tooltip');
|
||||
if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '开始旋转';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +294,9 @@ export function setAutoRotate(value) {
|
||||
export function toggleAutoRotate() {
|
||||
autoRotate = !autoRotate;
|
||||
updateRotateUI();
|
||||
clearLockedObject();
|
||||
if (window.clearLockedCable) {
|
||||
window.clearLockedCable();
|
||||
}
|
||||
return autoRotate;
|
||||
}
|
||||
|
||||
@@ -525,24 +307,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// info-card.js - Unified info card module
|
||||
import { showStatusMessage } from './ui.js';
|
||||
|
||||
let currentType = null;
|
||||
|
||||
@@ -30,39 +29,6 @@ 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: '超算详情',
|
||||
@@ -89,32 +55,7 @@ const CARD_CONFIG = {
|
||||
};
|
||||
|
||||
export function initInfoCard() {
|
||||
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';
|
||||
// Close button removed - now uses external clear button
|
||||
}
|
||||
|
||||
export function setInfoCardNoBorder(noBorder = true) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -1,172 +1,71 @@
|
||||
// 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");
|
||||
if (!statusEl) return;
|
||||
|
||||
if (statusTimeoutId) {
|
||||
clearTimeout(statusTimeoutId);
|
||||
statusTimeoutId = null;
|
||||
}
|
||||
|
||||
if (statusHideTimeoutId) {
|
||||
clearTimeout(statusHideTimeoutId);
|
||||
statusHideTimeoutId = null;
|
||||
}
|
||||
|
||||
if (statusReplayTimeoutId) {
|
||||
clearTimeout(statusReplayTimeoutId);
|
||||
statusReplayTimeoutId = null;
|
||||
}
|
||||
|
||||
const startShow = () => {
|
||||
export function showStatusMessage(message, type = 'info') {
|
||||
const statusEl = document.getElementById('status-message');
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `status-message ${type}`;
|
||||
statusEl.style.display = "block";
|
||||
statusEl.offsetHeight;
|
||||
statusEl.classList.add("visible");
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
statusTimeoutId = setTimeout(() => {
|
||||
statusEl.classList.remove("visible");
|
||||
statusHideTimeoutId = setTimeout(() => {
|
||||
statusEl.style.display = "none";
|
||||
statusEl.textContent = "";
|
||||
statusHideTimeoutId = null;
|
||||
}, 280);
|
||||
statusTimeoutId = null;
|
||||
setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 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) {
|
||||
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)}°`;
|
||||
}
|
||||
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)}°`;
|
||||
}
|
||||
|
||||
// Update zoom display
|
||||
export function updateZoomDisplay(zoomLevel, distance) {
|
||||
const percent = Math.round(zoomLevel * 100);
|
||||
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 + "%";
|
||||
document.getElementById('zoom-value').textContent = percent + '%';
|
||||
document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
|
||||
const slider = document.getElementById('zoom-slider');
|
||||
if (slider) slider.value = zoomLevel;
|
||||
if (cameraDistanceEl) cameraDistanceEl.textContent = distance + " km";
|
||||
document.getElementById('camera-distance').textContent = distance + ' km';
|
||||
}
|
||||
|
||||
// Update earth stats
|
||||
export function updateEarthStats(stats) {
|
||||
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 卫星图";
|
||||
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 卫星图';
|
||||
}
|
||||
|
||||
// Show/hide loading
|
||||
export function setLoading(loading) {
|
||||
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;
|
||||
}
|
||||
const loadingEl = document.getElementById('loading');
|
||||
loadingEl.style.display = loading ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Show tooltip
|
||||
export function showTooltip(x, y, content) {
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
if (!tooltip) return;
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
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() {
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
if (tooltip) {
|
||||
tooltip.style.display = "none";
|
||||
}
|
||||
document.getElementById('tooltip').style.display = 'none';
|
||||
}
|
||||
|
||||
// Show error message
|
||||
export function showError(message) {
|
||||
const errorEl = document.getElementById("error-message");
|
||||
if (!errorEl) return;
|
||||
const errorEl = document.getElementById('error-message');
|
||||
errorEl.textContent = message;
|
||||
errorEl.style.display = "block";
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// Hide error message
|
||||
export function hideError() {
|
||||
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();
|
||||
document.getElementById('error-message').style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -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,12 +18,10 @@ 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;
|
||||
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;
|
||||
let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
|
||||
|
||||
while (lon <= -180) lon += 360;
|
||||
while (lon > 180) lon -= 360;
|
||||
@@ -31,20 +29,15 @@ export function vector3ToLatLon(vector) {
|
||||
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,
|
||||
raycaster = new THREE.Raycaster(),
|
||||
mouse = new THREE.Vector2(),
|
||||
) {
|
||||
export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2();
|
||||
|
||||
if (domElement === document.body) {
|
||||
mouse.x = (clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.y = -(clientY / window.innerHeight) * 2 + 1;
|
||||
@@ -67,24 +60,15 @@ export function screenToEarthCoords(
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ 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()
|
||||
@@ -25,7 +24,6 @@ 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>
|
||||
|
||||
@@ -6,13 +6,11 @@ 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
|
||||
@@ -26,13 +24,11 @@ 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> },
|
||||
]
|
||||
@@ -78,10 +74,6 @@ 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>
|
||||
|
||||
@@ -173,6 +173,7 @@ body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -238,71 +239,12 @@ 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;
|
||||
@@ -319,10 +261,6 @@ 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 {
|
||||
@@ -419,8 +357,8 @@ body {
|
||||
|
||||
.table-scroll-region .ant-table-body::-webkit-scrollbar,
|
||||
.table-scroll-region .ant-table-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb,
|
||||
@@ -442,32 +380,6 @@ 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,
|
||||
@@ -620,8 +532,6 @@ 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,
|
||||
@@ -635,39 +545,6 @@ 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 {
|
||||
@@ -703,22 +580,6 @@ 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%);
|
||||
}
|
||||
@@ -799,24 +660,12 @@ 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: 8px;
|
||||
height: 8px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.82);
|
||||
background: rgba(148, 163, 184, 0.8);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
@@ -1019,13 +868,6 @@ 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;
|
||||
@@ -1044,22 +886,11 @@ 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;
|
||||
}
|
||||
@@ -1084,11 +915,6 @@ body {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.data-list-summary-section-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.data-list-detail-modal {
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -106,7 +105,7 @@ function Alerts() {
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => formatDateTimeZhCN(t),
|
||||
render: (t: string) => new Date(t).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -202,15 +201,15 @@ function Alerts() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="数据源">{selectedAlert.datasource_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="消息">{selectedAlert.message}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{formatDateTimeZhCN(selectedAlert.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{new Date(selectedAlert.created_at).toLocaleString('zh-CN')}</Descriptions.Item>
|
||||
{selectedAlert.acknowledged_at && (
|
||||
<Descriptions.Item label="确认时间">
|
||||
{formatDateTimeZhCN(selectedAlert.acknowledged_at)}
|
||||
{new Date(selectedAlert.acknowledged_at).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedAlert.resolved_at && (
|
||||
<Descriptions.Item label="解决时间">
|
||||
{formatDateTimeZhCN(selectedAlert.resolved_at)}
|
||||
{new Date(selectedAlert.resolved_at).toLocaleString('zh-CN')}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
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
|
||||
@@ -4,15 +4,12 @@ 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
|
||||
|
||||
@@ -171,21 +168,6 @@ 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 />} />
|
||||
@@ -205,7 +187,7 @@ function Dashboard() {
|
||||
|
||||
{stats?.last_updated && (
|
||||
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
|
||||
最后更新: {formatDateTimeZhCN(stats.last_updated)}
|
||||
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
|
||||
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, Card, Select, Input, Button, Segmented,
|
||||
Table, Tag, Space, Card, Select, Input, Button,
|
||||
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,
|
||||
ApartmentOutlined, EnvironmentOutlined
|
||||
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
|
||||
} 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
|
||||
@@ -20,7 +18,6 @@ const { useBreakpoint } = Grid
|
||||
interface CollectedData {
|
||||
id: number
|
||||
source: string
|
||||
source_name: string
|
||||
source_id: string
|
||||
data_type: string
|
||||
name: string
|
||||
@@ -44,15 +41,8 @@ interface CollectedData {
|
||||
|
||||
interface Summary {
|
||||
total_records: number
|
||||
overall_total_records: number
|
||||
by_source: Record<string, Record<string, 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
|
||||
source_totals: Array<{ source: string; count: number }>
|
||||
}
|
||||
|
||||
const DETAIL_FIELD_LABELS: Record<string, string> = {
|
||||
@@ -121,15 +111,12 @@ function formatDetailValue(key: string, value: unknown) {
|
||||
}
|
||||
|
||||
if (key === 'collected_at' || key === 'reference_date') {
|
||||
const date = parseBackendDate(String(value))
|
||||
if (!date) {
|
||||
return String(value)
|
||||
}
|
||||
const date = new Date(String(value))
|
||||
return Number.isNaN(date.getTime())
|
||||
? String(value)
|
||||
: key === 'reference_date'
|
||||
? formatDateZhCN(String(value))
|
||||
: formatDateTimeZhCN(String(value))
|
||||
? date.toLocaleDateString('zh-CN')
|
||||
: date.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
@@ -143,13 +130,6 @@ 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)
|
||||
@@ -242,56 +222,6 @@ 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
|
||||
@@ -309,7 +239,6 @@ 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)
|
||||
@@ -320,12 +249,11 @@ function DataList() {
|
||||
const [sourceFilter, setSourceFilter] = useState<string[]>([])
|
||||
const [typeFilter, setTypeFilter] = useState<string[]>([])
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [sources, setSources] = useState<SourceOption[]>([])
|
||||
const [sources, setSources] = useState<string[]>([])
|
||||
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 = () => {
|
||||
@@ -334,7 +262,6 @@ function DataList() {
|
||||
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
|
||||
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
|
||||
setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0)
|
||||
setSummaryBodyWidth(summaryBodyRef.current?.offsetWidth || 0)
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
@@ -362,7 +289,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) / 3), maxLeft))
|
||||
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
|
||||
|
||||
setLeftPanelWidth((current) => {
|
||||
if (!hasCustomLeftWidthRef.current) {
|
||||
@@ -420,13 +347,7 @@ function DataList() {
|
||||
|
||||
const fetchSummary = async () => {
|
||||
try {
|
||||
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')
|
||||
const res = await axios.get('/api/v1/collected/summary')
|
||||
setSummary(res.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch summary:', error)
|
||||
@@ -447,18 +368,17 @@ function DataList() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSummary()
|
||||
fetchFilters()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
fetchSummary()
|
||||
}, [page, pageSize, sourceFilter, typeFilter])
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1)
|
||||
fetchData()
|
||||
fetchSummary()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -467,7 +387,6 @@ function DataList() {
|
||||
setSearchText('')
|
||||
setPage(1)
|
||||
setTimeout(fetchData, 0)
|
||||
setTimeout(fetchSummary, 0)
|
||||
}
|
||||
|
||||
const handleViewDetail = async (id: number) => {
|
||||
@@ -495,67 +414,16 @@ 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',
|
||||
}
|
||||
|
||||
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]
|
||||
return colorMap[source] || 'blue'
|
||||
}
|
||||
|
||||
const getDataTypeTagColor = (dataType: string) => {
|
||||
@@ -587,86 +455,103 @@ 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 summaryKpis = useMemo(
|
||||
() => [
|
||||
{ key: 'total', label: '总记录', value: summary?.overall_total_records || 0, icon: <DatabaseOutlined /> },
|
||||
const summaryItems = useMemo(() => {
|
||||
const items = [
|
||||
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
|
||||
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
|
||||
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
|
||||
{
|
||||
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]
|
||||
)
|
||||
{ key: 'sources', label: '数据源数', value: sources.length, icon: <DatabaseOutlined /> },
|
||||
]
|
||||
|
||||
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: getDataTypeIcon(item.data_type),
|
||||
}))
|
||||
}
|
||||
|
||||
return summary.source_totals.map((item) => ({
|
||||
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
|
||||
items.push({
|
||||
key: item.source,
|
||||
label: item.source_name,
|
||||
label: item.source,
|
||||
value: item.count,
|
||||
icon: getSourceIcon(item.source),
|
||||
}))
|
||||
}, [summary, treemapDimension])
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [summary, total, activeFilterCount, isCompact, sources.length])
|
||||
|
||||
const treemapGap = isCompact ? 8 : 10
|
||||
const treemapMinCellSize = isCompact ? 72 : 52
|
||||
const treemapColumns = useMemo(() => {
|
||||
return getTreemapColumnCount(summaryBodyWidth, treemapMinCellSize, treemapGap, isCompact)
|
||||
}, [isCompact, summaryBodyWidth, treemapGap, treemapMinCellSize])
|
||||
if (isCompact) return 1
|
||||
if (leftPanelWidth < 360) return 2
|
||||
if (leftPanelWidth < 520) return 3
|
||||
return 4
|
||||
}, [isCompact, leftPanelWidth])
|
||||
|
||||
const treemapItems = useMemo(() => {
|
||||
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
|
||||
const maxItems = isCompact ? 6 : 10
|
||||
const limitedItems = distributionItems.slice(0, maxItems)
|
||||
const maxValue = Math.max(...limitedItems.map((item) => item.value), 1)
|
||||
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
|
||||
const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2
|
||||
const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520
|
||||
|
||||
return limitedItems.map((item, index) => {
|
||||
const span = Math.min(getTreemapSpan(item.value, maxValue, treemapColumns), treemapColumns)
|
||||
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 {
|
||||
...item,
|
||||
colSpan: span,
|
||||
rowSpan: span,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
tone: palette[index % palette.length],
|
||||
}
|
||||
})
|
||||
}, [distributionItems, isCompact, treemapColumns])
|
||||
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
|
||||
|
||||
const treemapRows = useMemo(
|
||||
() => estimateTreemapRows(treemapItems, treemapColumns),
|
||||
[treemapColumns, treemapItems]
|
||||
)
|
||||
|
||||
const treemapBaseSize = Math.max(
|
||||
treemapMinCellSize,
|
||||
getTreemapBaseSize(summaryBodyWidth, treemapColumns, treemapGap, treemapMinCellSize)
|
||||
)
|
||||
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 treemapRowHeight = treemapBaseSize
|
||||
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 treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap
|
||||
const { tilePadding: treemapTilePadding, labelSize: treemapLabelSize, valueSize: treemapValueSize } =
|
||||
getTreemapTypography(treemapRowHeight)
|
||||
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 pageHeight = '100%'
|
||||
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
|
||||
@@ -679,7 +564,7 @@ function DataList() {
|
||||
return DETAIL_BASE_FIELDS.map((key) => ({
|
||||
key,
|
||||
label: formatFieldLabel(key),
|
||||
value: formatDetailValue(key, getDetailFieldValue(detailData, key)),
|
||||
value: formatDetailValue(key, detailData[key as keyof CollectedData]),
|
||||
})).filter((item) => item.value !== '-')
|
||||
}, [detailData])
|
||||
|
||||
@@ -720,11 +605,11 @@ function DataList() {
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
minWidth: 140,
|
||||
render: (_: string, record: CollectedData) => (
|
||||
record.source ? (
|
||||
render: (value: string) => (
|
||||
value ? (
|
||||
<div className="data-list-tag-cell">
|
||||
<Tag color={getSourceTagColor(record.source)} style={{ marginInlineEnd: 0 }}>
|
||||
{record.source_name || record.source}
|
||||
<Tag color={getSourceTagColor(value)} style={{ marginInlineEnd: 0 }}>
|
||||
{value}
|
||||
</Tag>
|
||||
</div>
|
||||
) : '-'
|
||||
@@ -750,14 +635,14 @@ function DataList() {
|
||||
dataIndex: 'collected_at',
|
||||
key: 'collected_at',
|
||||
width: 180,
|
||||
render: (time: string) => formatDateTimeZhCN(time),
|
||||
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '参考日期',
|
||||
dataIndex: 'reference_date',
|
||||
key: 'reference_date',
|
||||
width: 120,
|
||||
render: (time: string | null) => formatDateZhCN(time),
|
||||
render: (time: string | null) => (time ? new Date(time).toLocaleDateString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -797,31 +682,6 @@ 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={{
|
||||
@@ -835,40 +695,26 @@ function DataList() {
|
||||
['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
{treemapItems.length > 0 ? treemapItems.map((item) => (
|
||||
{treemapItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}${isCompactTreemapItem(item) ? ' data-list-treemap-tile--compact' : ''}`}
|
||||
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
|
||||
style={{
|
||||
gridColumn: `span ${item.colSpan}`,
|
||||
gridRow: `span ${item.rowSpan}`,
|
||||
}}
|
||||
>
|
||||
<div className="data-list-treemap-head">
|
||||
<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"
|
||||
style={{ fontSize: getTreemapItemValueSize(item, treemapValueSize) }}
|
||||
>
|
||||
<Text strong className="data-list-summary-tile-value">
|
||||
{item.value.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="data-list-summary-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无分布数据" />
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -910,7 +756,7 @@ function DataList() {
|
||||
setSourceFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={sources.map((source) => ({ label: source.source_name, value: source.source }))}
|
||||
options={sources.map((source) => ({ label: source, value: source }))}
|
||||
tagRender={(tagProps) => renderFilterTag(tagProps, getSourceTagColor)}
|
||||
style={{ width: '100%' }}
|
||||
className="data-list-filter-select"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, message, Button, Form, Input, Select, Progress, Checkbox,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||
Table, Tag, Space, message, Button, Form, Input, Select,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
|
||||
} from 'antd'
|
||||
import {
|
||||
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
|
||||
@@ -11,8 +11,6 @@ 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
|
||||
@@ -24,10 +22,6 @@ 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
|
||||
@@ -44,22 +38,6 @@ 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 {
|
||||
@@ -100,8 +78,6 @@ 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)
|
||||
@@ -109,7 +85,7 @@ function DataSources() {
|
||||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [builtinRes, customRes] = await Promise.all([
|
||||
@@ -123,72 +99,13 @@ 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 = () => {
|
||||
@@ -213,8 +130,6 @@ 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))
|
||||
@@ -271,28 +186,22 @@ function DataSources() {
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||||
}, [builtInSources, taskProgress])
|
||||
|
||||
const handleTrigger = async (id: number) => {
|
||||
try {
|
||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
|
||||
message.success('任务已触发')
|
||||
if (res.data.task_id) {
|
||||
setTaskProgress(prev => ({
|
||||
...prev,
|
||||
[id]: {
|
||||
task_id: res.data.task_id,
|
||||
task_id: res.data.task_id ?? null,
|
||||
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 } } }
|
||||
@@ -300,52 +209,6 @@ 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 {
|
||||
@@ -542,15 +405,8 @@ function DataSources() {
|
||||
title: '最近采集',
|
||||
dataIndex: 'last_run',
|
||||
key: 'last_run',
|
||||
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
|
||||
},
|
||||
width: 140,
|
||||
render: (lastRun: string | null) => lastRun || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
@@ -575,6 +431,7 @@ 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)}%` : ''}
|
||||
@@ -582,26 +439,7 @@ function DataSources() {
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
return <Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -615,7 +453,6 @@ function DataSources() {
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SyncOutlined />}
|
||||
disabled={!record.is_active}
|
||||
onClick={() => handleTrigger(record.id)}
|
||||
>
|
||||
触发
|
||||
@@ -624,8 +461,6 @@ 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 ? '禁用' : '启用'}
|
||||
@@ -701,47 +536,7 @@ function DataSources() {
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<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 className="page-shell__body">
|
||||
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
columns={builtinColumns}
|
||||
@@ -1059,37 +854,31 @@ function DataSources() {
|
||||
}
|
||||
>
|
||||
{viewingSource && (
|
||||
<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 layout="vertical">
|
||||
<Form.Item label="名称">
|
||||
<Input value={viewingSource.name} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="数据量">
|
||||
<Input value={`${recordCount} 条`} disabled />
|
||||
</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 />
|
||||
@@ -1109,7 +898,7 @@ function DataSources() {
|
||||
key: 'auth',
|
||||
label: '认证配置',
|
||||
children: (
|
||||
<Form.Item label="认证方式" style={{ marginBottom: 0 }}>
|
||||
<Form.Item label="认证方式">
|
||||
<Input value={viewingSource.auth_type || 'none'} disabled />
|
||||
</Form.Item>
|
||||
),
|
||||
@@ -1118,7 +907,7 @@ function DataSources() {
|
||||
key: 'headers',
|
||||
label: '请求头',
|
||||
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
|
||||
{JSON.stringify(viewingSource.headers, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
@@ -1129,7 +918,7 @@ function DataSources() {
|
||||
key: 'config',
|
||||
label: '高级配置',
|
||||
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
|
||||
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto' }}>
|
||||
{JSON.stringify(viewingSource.config, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
@@ -1139,7 +928,6 @@ function DataSources() {
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Space>
|
||||
)}
|
||||
</Drawer>
|
||||
</AppLayout>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from 'antd'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import { formatDateTimeZhCN } from '../../utils/datetime'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
@@ -221,14 +220,14 @@ function Settings() {
|
||||
dataIndex: 'last_run_at',
|
||||
key: 'last_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '下次执行',
|
||||
dataIndex: 'next_run_at',
|
||||
key: 'next_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatDateTimeZhCN(value),
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -94,7 +93,7 @@ function Tasks() {
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
key: 'started_at',
|
||||
render: (t: string) => formatDateTimeZhCN(t),
|
||||
render: (t: string) => t ? new Date(t).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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 users-table-region" style={{ height: '100%' }}>
|
||||
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region" style={{ height: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
@@ -64,7 +64,6 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
entries: ['src/main.tsx'],
|
||||
exclude: ['satellite.js'],
|
||||
},
|
||||
})
|
||||
|
||||
40
kill_port.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/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 "=========================================="
|
||||
374
planet.sh
@@ -11,15 +11,6 @@ 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}"
|
||||
|
||||
@@ -62,362 +53,57 @@ ensure_frontend_deps() {
|
||||
fi
|
||||
}
|
||||
|
||||
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"
|
||||
start() {
|
||||
echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
|
||||
|
||||
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
|
||||
sleep 3
|
||||
|
||||
echo -e "${BLUE}🔧 启动后端...${NC}"
|
||||
ensure_uv_backend_deps
|
||||
if ! start_backend_with_retry "$backend_port"; then
|
||||
echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}"
|
||||
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}"
|
||||
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
|
||||
if ! start_frontend_with_retry "$frontend_port"; then
|
||||
echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}"
|
||||
tail -10 /tmp/planet_frontend.log
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
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 &
|
||||
|
||||
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"
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ 启动完成!${NC}"
|
||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
||||
echo " 前端: http://localhost:3000"
|
||||
echo " 后端: http://localhost:8000"
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo -e "${YELLOW}🛑 停止服务...${NC}"
|
||||
stop_backend_service
|
||||
stop_frontend_service
|
||||
pkill -f "uvicorn" 2>/dev/null || true
|
||||
pkill -f "vite" 2>/dev/null || true
|
||||
pkill -f "bun run dev" 2>/dev/null || true
|
||||
docker stop planet_postgres planet_redis 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ 已停止${NC}"
|
||||
}
|
||||
|
||||
restart() {
|
||||
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() {
|
||||
@@ -461,18 +147,13 @@ log() {
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
shift
|
||||
start "$@"
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
restart)
|
||||
shift
|
||||
restart "$@"
|
||||
;;
|
||||
createuser)
|
||||
create_user
|
||||
restart
|
||||
;;
|
||||
health)
|
||||
health
|
||||
@@ -481,13 +162,12 @@ case "$1" in
|
||||
log "$2"
|
||||
;;
|
||||
*)
|
||||
echo "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
|
||||
echo "用法: ./planet.sh {start|stop|restart|health|log}"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " start 启动服务,可选: -b <后端端口> -f <前端端口>"
|
||||
echo " start 启动服务"
|
||||
echo " stop 停止服务"
|
||||
echo " restart 重启服务,可选: -b [后端端口] -f [前端端口]"
|
||||
echo " createuser 交互创建用户"
|
||||
echo " restart 重启服务"
|
||||
echo " health 检查健康状态"
|
||||
echo " log 查看日志"
|
||||
echo " log -f 查看前端日志"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.21.0"
|
||||
version = "1.0.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||