release: bump version to 0.36.0

This commit is contained in:
rayd1o
2026-04-22 23:42:10 +08:00
parent 6a5f9f7ad4
commit abe04030fb
23 changed files with 1875 additions and 27 deletions

114
README.md
View File

@@ -227,6 +227,120 @@ bun run build
启动服务后访问: `http://localhost:8000/docs`
## WSL / Windows 局域网访问
如果服务运行在 WSL 中,而你希望:
- Windows 本机浏览器访问开发服务
- 同一局域网内的手机或其他电脑访问开发服务
推荐按下面顺序排查和配置。
### 1. 在 WSL 中启动服务
```bash
./planet.sh start --allow-lan
```
这会让前端监听 `0.0.0.0:3000`,后端监听 `0.0.0.0:8000`。
### 2. 先确认 WSL 内部服务正常
在 WSL 中执行:
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
预期:
- `3000` 返回前端 HTML
- `8000/health` 返回健康检查 JSON
- `ss` 中能看到 `0.0.0.0:3000` 和 `0.0.0.0:8000`
如果这一步不通,先不要继续做 Windows 转发。
### 3. 在 Windows 本机验证 localhost 直通
在 Windows PowerShell 中执行:
```powershell
curl http://localhost:3000
curl http://localhost:8000/health
```
在常见的 WSL2 开发环境下,Windows 通常可以直接通过 `localhost` 访问 WSL 中的服务。
### 4. 如果需要让局域网设备访问,再做 Windows 端口转发
注意:下面的命令必须在“以管理员身份运行”的 PowerShell 中执行。
先把 Windows 对外网卡上的 `3000` / `8000` 转发到 Windows 本机 `127.0.0.1`:
```powershell
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=3000
netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
```
再放行 Windows 防火墙:
```powershell
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
检查转发规则是否生效:
```powershell
netsh interface portproxy show all
```
预期能看到:
- `0.0.0.0:3000 -> 127.0.0.1:3000`
- `0.0.0.0:8000 -> 127.0.0.1:8000`
### 5. 查 Windows 局域网 IP,并让其他设备访问
在 Windows PowerShell 中执行:
```powershell
ipconfig
```
找到当前联网网卡的 IPv4 地址,例如 `192.168.8.228`。
局域网其他设备可访问:
- `http://<Windows局域网IP>:3000/earth`
- `http://<Windows局域网IP>:3000/admin`
例如:
- `http://192.168.8.228:3000/earth`
### 6. 常见现象与判断
- WSL 中 `curl localhost:3000` 能通,但 Windows 访问 `WSL 的局域网 IP:3000` 不通:这是正常现象之一,优先验证 Windows 的 `localhost:3000`
- Windows `localhost:3000` 能通,但局域网设备访问 `Windows 局域网 IP:3000` 不通:通常缺少 `portproxy` 或防火墙放行
- `whoami /groups` 中 `S-1-5-32-544` 显示 `deny only`:说明当前 PowerShell 不是提权管理员窗口
### 7. 本项目一次性验证顺序
建议固定按这个顺序验证:
1. WSL 中执行 `curl http://localhost:3000`
2. WSL 中执行 `curl http://localhost:8000/health`
3. Windows 中执行 `curl http://localhost:3000`
4. Windows 中执行 `curl http://localhost:8000/health`
5. 管理员 PowerShell 配置 `portproxy` 和防火墙
6. 用手机或其他电脑访问 `http://<Windows局域网IP>:3000/earth`
## 启动容错参数
`planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。

View File

@@ -1 +1 @@
0.35.1
0.36.0

View File

@@ -13,6 +13,7 @@ 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.countries import get_country_centroid
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
@@ -363,6 +364,215 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An
return {"type": "FeatureCollection", "features": features}
def _parse_float(value: Any) -> Optional[float]:
try:
if value in (None, ""):
return None
return float(value)
except (TypeError, ValueError):
return None
COMPUTE_CENTER_COORDINATE_HINTS = (
("el capitan", 37.6819, -121.7681),
("livermore", 37.6819, -121.7681),
("llnl", 37.6819, -121.7681),
("lawrence livermore", 37.6819, -121.7681),
("frontier", 35.9319, -84.3107),
("oak ridge", 35.9319, -84.3107),
("ornl", 35.9319, -84.3107),
("aurora", 41.7130, -87.9820),
("argonne", 41.7130, -87.9820),
("anl", 41.7130, -87.9820),
("fugaku", 34.6953, 135.1974),
("kobe", 34.6953, 135.1974),
("riken", 34.6953, 135.1974),
("summit", 35.9319, -84.3107),
("leonardo", 44.4949, 11.3426),
("bologna", 44.4949, 11.3426),
("alps", 46.0037, 8.9511),
("lugano", 46.0037, 8.9511),
("sunway taihulight", 31.4912, 120.3119),
("wuxi", 31.4912, 120.3119),
("tianhe-2", 23.1291, 113.2644),
("tianhe-2a", 23.1291, 113.2644),
("guangzhou", 23.1291, 113.2644),
("colossus", 35.1495, -90.0490),
("memphis", 35.1495, -90.0490),
("xai", 35.1495, -90.0490),
)
def _normalize_hint_text(*parts: Any) -> str:
return " ".join(
str(part).strip().lower()
for part in parts
if part not in (None, "")
)
def _resolve_compute_center_coordinates(
record: CollectedData,
metadata: Dict[str, Any],
) -> Dict[str, Any]:
latitude = _parse_float(get_record_field(record, "latitude"))
longitude = _parse_float(get_record_field(record, "longitude"))
if latitude not in (None, 0.0) and longitude not in (None, 0.0):
return {
"latitude": latitude,
"longitude": longitude,
"location_precision": "precise",
"geography_mode": "source_coordinates",
"is_estimated": False,
"estimated_reason": None,
}
hint_text = _normalize_hint_text(
record.name,
get_record_field(record, "city"),
get_record_field(record, "country"),
metadata.get("site"),
metadata.get("organization"),
metadata.get("operator"),
)
for needle, resolved_latitude, resolved_longitude in COMPUTE_CENTER_COORDINATE_HINTS:
if needle in hint_text:
return {
"latitude": resolved_latitude,
"longitude": resolved_longitude,
"location_precision": "estimated_site",
"geography_mode": "site_hint",
"is_estimated": True,
"estimated_reason": f"Matched known site hint: {needle}",
}
centroid = get_country_centroid(get_record_field(record, "country"))
if centroid:
return {
"latitude": centroid.get("latitude"),
"longitude": centroid.get("longitude"),
"location_precision": "estimated_country",
"geography_mode": "country_centroid",
"is_estimated": True,
"estimated_reason": "Estimated from country centroid",
}
return {
"latitude": latitude,
"longitude": longitude,
"location_precision": "unknown",
"geography_mode": "unknown",
"is_estimated": True,
"estimated_reason": "No resolvable location hints",
}
def _normalize_capacity_band(capacity_value: Optional[float], capacity_unit: str) -> str:
if capacity_value is None:
return "unknown"
unit = str(capacity_unit or "").strip().lower()
if unit in {"pflop/s", "pflops", "pflop"}:
normalized_tflops = capacity_value * 1000
elif unit in {"gflop/s", "gflops", "gflop"}:
normalized_tflops = capacity_value / 1000
else:
normalized_tflops = capacity_value
if normalized_tflops >= 1_000_000:
return "exascale"
if normalized_tflops >= 100_000:
return "ultra"
if normalized_tflops >= 10_000:
return "large"
if normalized_tflops > 0:
return "regional"
return "unknown"
def convert_compute_centers_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
"""Convert compute infrastructure records into a unified GeoJSON layer."""
features = []
for record in records:
metadata = record.extra_data or {}
coordinate_info = _resolve_compute_center_coordinates(record, metadata)
latitude = coordinate_info.get("latitude")
longitude = coordinate_info.get("longitude")
site_type = (
"supercomputer"
if record.source == "top500" or record.data_type == "supercomputer"
else "gpu_cluster"
)
if latitude in (None, 0.0) or longitude in (None, 0.0):
continue
if site_type == "supercomputer":
capacity_value = _parse_float(get_record_field(record, "rmax"))
capacity_unit = "GFlops"
else:
capacity_value = _parse_float(get_record_field(record, "value"))
capacity_unit = str(get_record_field(record, "unit") or "TFlop/s")
vendor = (
metadata.get("manufacturer")
or metadata.get("vendor")
or metadata.get("gpu_type")
)
operator = (
metadata.get("organization")
or metadata.get("operator")
or metadata.get("owner")
)
rank = metadata.get("rank")
if rank in (None, "") and site_type == "supercomputer":
rank = get_record_field(record, "rank")
updated_at = to_iso8601_utc(record.reference_date or record.collected_at)
features.append(
{
"type": "Feature",
"id": record.id,
"geometry": {
"type": "Point",
"coordinates": [longitude or 0, latitude or 0],
},
"properties": {
"id": record.id,
"source_id": record.source_id,
"name": record.name,
"site_type": site_type,
"country": get_record_field(record, "country"),
"city": get_record_field(record, "city"),
"latitude": latitude,
"longitude": longitude,
"operator": operator,
"vendor": vendor,
"capacity_value": capacity_value,
"capacity_unit": capacity_unit,
"capacity_band": _normalize_capacity_band(capacity_value, capacity_unit),
"rank": rank,
"gpu_count": metadata.get("gpu_count"),
"gpu_type": metadata.get("gpu_type"),
"cores": get_record_field(record, "cores"),
"power": get_record_field(record, "power"),
"source": record.source,
"updated_at": updated_at,
"status": "observed",
"location_precision": coordinate_info.get("location_precision"),
"geography_mode": coordinate_info.get("geography_mode"),
"is_estimated": coordinate_info.get("is_estimated", False),
"estimated_reason": coordinate_info.get("estimated_reason"),
"data_type": "compute_center",
"metadata": metadata,
},
}
)
return {"type": "FeatureCollection", "features": features}
def convert_bgp_anomalies_to_geojson(
records: List[BGPAnomaly],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
@@ -975,6 +1185,53 @@ async def get_gpu_clusters_geojson(
}
@router.get("/geo/compute-centers")
async def get_compute_centers_geojson(
limit: int = Query(200, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
):
"""获取统一算力中心 GeoJSON 数据"""
records_by_source = await _load_current_collected_data_by_sources(
db,
["top500", "epoch_ai_gpu"],
)
records = _filter_known_records(
records_by_source.get("top500", []) + records_by_source.get("epoch_ai_gpu", []),
)
if limit is not None:
records = records[:limit]
if not records:
return {
"type": "FeatureCollection",
"features": [],
"count": 0,
"stats": {
"total": 0,
"supercomputers": 0,
"gpu_clusters": 0,
},
}
geojson = convert_compute_centers_to_geojson(records)
features = geojson.get("features", [])
return {
**geojson,
"count": len(features),
"stats": {
"total": len(features),
"supercomputers": sum(
1 for feature in features
if feature.get("properties", {}).get("site_type") == "supercomputer"
),
"gpu_clusters": sum(
1 for feature in features
if feature.get("properties", {}).get("site_type") == "gpu_cluster"
),
},
}
@router.get("/geo/bgp-anomalies")
async def get_bgp_anomalies_geojson(
severity: Optional[str] = Query(None),

View File

@@ -0,0 +1,217 @@
from datetime import datetime, timezone
import pytest
from httpx import ASGITransport, AsyncClient
from app.api.v1.visualization import convert_compute_centers_to_geojson
from app.db.session import get_db
from app.main import app
from app.models.collected_data import CollectedData
def _build_record(
*,
record_id: int,
source: str,
data_type: str,
name: str,
country: str,
city: str,
latitude: float,
longitude: float,
metadata: dict,
):
return CollectedData(
id=record_id,
source=source,
data_type=data_type,
source_id=f"{source}-{record_id}",
name=name,
extra_data={
"country": country,
"city": city,
"latitude": latitude,
"longitude": longitude,
**metadata,
},
collected_at=datetime(2026, 4, 22, tzinfo=timezone.utc),
reference_date=datetime(2026, 4, 21, tzinfo=timezone.utc),
is_current=True,
)
def test_convert_compute_centers_to_geojson_unifies_sources():
top500_record = _build_record(
record_id=1,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="Oak Ridge",
latitude=35.93,
longitude=-84.31,
metadata={
"rank": 1,
"manufacturer": "HPE",
"organization": "ORNL",
"rmax": 1102000.0,
"cores": 8730112,
"power": 21510.0,
},
)
gpu_record = _build_record(
record_id=2,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Colossus",
country="United States",
city="Memphis",
latitude=35.15,
longitude=-90.05,
metadata={
"organization": "xAI",
"gpu_type": "H100",
"gpu_count": 100000,
"value": "20000",
"unit": "TFlop/s",
},
)
payload = convert_compute_centers_to_geojson([top500_record, gpu_record])
assert payload["type"] == "FeatureCollection"
assert len(payload["features"]) == 2
supercomputer_feature = payload["features"][0]
assert supercomputer_feature["properties"]["site_type"] == "supercomputer"
assert supercomputer_feature["properties"]["capacity_unit"] == "GFlops"
assert supercomputer_feature["properties"]["capacity_band"] == "exascale"
assert supercomputer_feature["properties"]["operator"] == "ORNL"
assert supercomputer_feature["properties"]["location_precision"] == "precise"
assert supercomputer_feature["properties"]["is_estimated"] is False
gpu_feature = payload["features"][1]
assert gpu_feature["properties"]["site_type"] == "gpu_cluster"
assert gpu_feature["properties"]["vendor"] == "H100"
assert gpu_feature["properties"]["gpu_count"] == 100000
assert gpu_feature["properties"]["capacity_band"] == "large"
assert gpu_feature["properties"]["location_precision"] == "precise"
def test_convert_compute_centers_to_geojson_uses_coordinate_hints():
hinted_record = _build_record(
record_id=3,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="",
latitude=0.0,
longitude=0.0,
metadata={
"organization": "Oak Ridge National Laboratory",
"rmax": 1102000.0,
},
)
payload = convert_compute_centers_to_geojson([hinted_record])
assert len(payload["features"]) == 1
coords = payload["features"][0]["geometry"]["coordinates"]
assert coords[0] == pytest.approx(-84.3107)
assert coords[1] == pytest.approx(35.9319)
assert payload["features"][0]["properties"]["is_estimated"] is True
assert payload["features"][0]["properties"]["location_precision"] == "estimated_site"
def test_convert_compute_centers_to_geojson_falls_back_to_country_centroid():
centroid_record = _build_record(
record_id=4,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Unknown Cluster",
country="United States",
city="",
latitude=0.0,
longitude=0.0,
metadata={
"organization": "Unknown Operator",
"value": "10000",
"unit": "TFlop/s",
},
)
payload = convert_compute_centers_to_geojson([centroid_record])
assert len(payload["features"]) == 1
props = payload["features"][0]["properties"]
coords = payload["features"][0]["geometry"]["coordinates"]
assert coords[0] == pytest.approx(-98.5795)
assert coords[1] == pytest.approx(39.8283)
assert props["is_estimated"] is True
assert props["location_precision"] == "estimated_country"
assert props["geography_mode"] == "country_centroid"
@pytest.mark.asyncio
async def test_compute_centers_geojson_endpoint_returns_stats():
records = [
_build_record(
record_id=1,
source="top500",
data_type="supercomputer",
name="Frontier",
country="United States",
city="Oak Ridge",
latitude=35.93,
longitude=-84.31,
metadata={"rank": 1, "rmax": 1102000.0},
),
_build_record(
record_id=2,
source="epoch_ai_gpu",
data_type="gpu_cluster",
name="Colossus",
country="United States",
city="Memphis",
latitude=35.15,
longitude=-90.05,
metadata={"value": "20000", "unit": "TFlop/s"},
),
]
class _ScalarResult:
def __init__(self, rows):
self._rows = rows
def scalars(self):
class _Scalars:
def __init__(self, rows):
self._rows = rows
def all(self):
return self._rows
return _Scalars(self._rows)
class _FakeSession:
async def execute(self, _query):
return _ScalarResult(records)
async def override_get_db():
yield _FakeSession()
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/visualization/geo/compute-centers")
assert response.status_code == 200
data = response.json()
assert data["count"] == 2
assert data["stats"]["supercomputers"] == 1
assert data["stats"]["gpu_clusters"] == 1
assert data["features"][0]["properties"]["data_type"] == "compute_center"
finally:
app.dependency_overrides.clear()

View File

@@ -8,6 +8,24 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.35.1] — 2026-04-22
## [0.36.0] — 2026-04-22
### ✨ Highlights
- Earth 新增统一“算力中心”图层:接入超算与 GPU 集群,支持搜索、统计、图例、详情卡与独立图层开关
- 算力中心支持精确位置与估算位置两种状态,估算点会以问号角标区分,避免数据不全时整批节点在地图上消失
### 🔧 Improvements
- Earth 详情卡拖拽与地球拖拽交互继续收口,减少拖动卡片和旋转地球时的选中文本与 pointer 竞争
- `planet.sh` 改为通过独立脚本计算 AI Provider 依赖指纹,降低与根仓库依赖版本文件的无关耦合
- README 补充 WSL / Windows 局域网访问排查与转发配置说明,便于开发环境联调
### 🐛 Fixes
- 修复 Earth 算力中心图层在无原始坐标时无法显示的问题,支持站点提示和国家级估算回退
- 修复信息卡拖拽事件可能被卡片级 stopPropagation 吞掉,导致拖拽流中断的问题
---
## [0.35.1] — 2026-04-22
### ✨ Highlights

View File

@@ -17,6 +17,7 @@
当前重点入口:
- [earth-mobile-drawer-ui-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
- [earth-compute-center-bgp-style-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
- [earth-renderer-architecture-separation-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
- [earth-predicted-orbit-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-predicted-orbit-plan.md)
- [earth-webgl-instancing-satellites-plan.md](/home/ray/dev/linkong/planet/docs/plans/earth-webgl-instancing-satellites-plan.md)

View File

@@ -0,0 +1,312 @@
# Earth Compute Center BGP-Style Plan
## Goal
这份文档定义如何按照 BGP 模块的产品方式,把“算力中心”提升为 Earth 上的一级能力。
这里的“按 BGP 方式”指的是:
- 有独立的数据语义和接口入口
- 有独立的 Earth 图层与图例
- 有独立的 hover / click / 选中态 / 详情卡
- 有独立的统计口径与后续专题页扩展空间
这里的“按 BGP 方式”不指:
- 机械复制 BGP 的 anomaly / incident / collector 三层事件模型
- 为静态算力设施强行引入不必要的复杂告警语义
算力中心本质上更接近“长期基础设施分布层”,不是“高频动态异常层”。
因此应该复用 BGP 的模块化方法,而不是照搬 BGP 的事件结构。
## Why
当前仓库里已经有算力相关基础:
- 后端已有 `top500` 和 `epoch_ai_gpu` 数据采集
- 可视化接口已有 `/api/v1/visualization/geo/supercomputers` 和 `/api/v1/visualization/geo/gpu-clusters`
- Earth 信息卡已对 `supercomputer` 和 `gpu_cluster` 做了基础类型兼容
但当前能力还停留在“数据可取到”的阶段,没有形成像 BGP 那样完整的可视化模块:
- Earth 缺少独立的算力图层加载模块
- 缺少算力 marker 体系和视觉层级
- 缺少算力图例、统计、开关和搜索接入
- 缺少与海缆、BGP、卫星的关系表达
- 缺少算力专题页和后续告警/研判扩展入口
所以当前真正的缺口不是“有没有数据”,而是“有没有产品级模块”。
## Core Principle
算力中心应当采用和 BGP 一致的模块化分层:
1. 数据层:稳定的数据契约和 GeoJSON 输出
2. 渲染层:独立的 Earth 图层、marker 和视觉状态管理
3. 交互层:hover、click、锁定态、详情卡、图例和统计
4. 扩展层:后续专题页、关系分析、告警和 AI 研判
但语义上必须保持算力中心自身的特点:
- `site / center` 是主对象,不是事件
- `capacity / rank / vendor / operator / status` 是主信息,不是异常严重度
- `distribution / concentration / dependency` 是后续分析方向,不是第一阶段必须项
## Recommended Scope
第一版“算力中心”建议统一承载两类对象:
- `supercomputer`
- `gpu_cluster`
并在 Earth 上收口为一个主题层:`compute_centers`
这样做有几个好处:
- 用户看到的是统一的“算力基础设施”语义,而不是零散数据源
- 后端仍可保留 `top500` 和 `epoch_ai_gpu` 的来源差异
- 前端可以在一个图层里再细分两种 marker 语言
## Current Gap
和 BGP 对比,当前差距主要在下面几层。
### 1. Data Contract Gap
现在的算力 GeoJSON 还是通用 `collected_data` 输出思路,字段较轻:
- `gpu_cluster` 只有基础名称和地点
- `supercomputer` 只暴露一部分性能字段
- 缺少统一的 `site_type / operator / capacity_band / source / updated_at / confidence`
- 缺少统一的算力层聚合出口
### 2. Earth Rendering Gap
当前 Earth 里没有类似 `bgp.js` 的算力模块:
- `constants.js` 没有算力 API 路径和视觉配置
- `main.js` 没有算力加载、拾取、状态同步和 HUD 更新
- `controls.js` 没有算力图层开关和启动加载优先级
- `layer-startup-tasks.js` 没有算力启动任务
- `legend.js` / `ui.js` 没有算力统计与图例模式
### 3. Interaction Gap
虽然 `info-card.js` 支持基础字段,但还没有形成 BGP 那种完整交互链路:
- 没有 hover / selected / dimmed 的视觉状态
- 没有算力对象专属 tooltip 与摘要文案
- 没有锁定后与其他基础设施的联动高亮
- 没有搜索、统计卡和详情组织方式
### 4. Product Expansion Gap
当前还没有“算力中心”专题页与分析语义:
- 没有全球分布/国家聚合/厂商聚合视图
- 没有算力与海缆/BGP/区域的关系表达
- 没有 AI brief / assessment 的后续落点
## Architecture Direction
推荐把算力中心做成“BGP 同级能力”,但采用更适合静态基础设施的结构。
### Backend
建议新增统一聚合接口,例如:
- `/api/v1/visualization/geo/compute-centers`
它的职责是把:
- `top500`
- `epoch_ai_gpu`
统一转换成一个主题层输出,同时保留对象细分类型:
- `site_type: supercomputer | gpu_cluster`
建议统一字段至少包括:
- `id`
- `name`
- `site_type`
- `country`
- `city`
- `latitude`
- `longitude`
- `operator`
- `vendor`
- `capacity_value`
- `capacity_unit`
- `capacity_band`
- `rank`
- `source`
- `updated_at`
- `location_precision`
- `geography_mode`
- `is_estimated`
- `estimated_reason`
- `metadata`
这里建议优先做“统一聚合出口”,而不是一开始就新增独立数据库表。
原因:
- 当前源数据更新频率低,先复用 `collected_data` 成本更低
- 可以先把 Earth 产品体验做完整
- 如果后续要做历史趋势、关系推断、告警,再评估是否拆成独立模型
### Frontend Earth
建议新增独立模块,例如:
- `frontend/public/earth/js/compute-centers.js`
职责参照 `bgp.js`:
- 拉取算力中心 GeoJSON
- 创建 marker
- 管理 hover / selected / dimmed 状态
- 输出图例项
- 输出统计摘要
- 提供 overlay 和详情格式化辅助函数
推荐视觉分层:
1. `supercomputer` 用更稳定、更规整的设施型符号
2. `gpu_cluster` 用更活跃、更现代的密度型符号
3. 选中态通过 halo / ring / related infrastructure highlight 表达
视觉上应避免把算力中心做成“BGP 事件点”那种高频脉冲风格。
它应该更像长期存在的高价值设施。
## Phases
## Phase 1: Unified Earth Layer
目标:
- 先把算力中心做成 Earth 上可用、可点、可解释的一级图层
工作项:
- 新增统一算力 GeoJSON 接口
- 新增 `compute-centers.js`
- 在 `constants.js` 增加 API 路径和视觉配置
- 在 `controls.js` 增加算力图层开关与启动元数据
- 在 `layer-startup-tasks.js` 增加算力启动加载任务
- 在 `main.js` 接入算力拾取、hover、click、锁定态和 HUD 统计
- 在 `ui.js` / `legend.js` / `index.html` 增加算力统计与图例入口
- 在 `info-card.js` 提升算力详情字段组织
- 对无法精确定位、但可按国家或弱线索推测的大概位置,仍然生成地图点位
- 这类对象必须带显式“估算位置”状态,例如图标问号角标与详情说明
完成标准:
- Earth 上能独立显示/隐藏算力中心
- 两类对象有可区分的视觉表达
- hover / click / 详情卡 / 图例 / 统计全部打通
- 精确位置与估算位置在图标或文案上可区分,不会误导为同一精度
- 不干扰现有海缆、卫星、BGP 的交互链路
## Phase 2: Relationship Layer
目标:
- 让算力中心不只是“点”,而是和其他基础设施产生上下文关系
工作项:
- 建立算力中心与国家/区域聚合摘要
- 增加与附近海缆登陆点的关系提示
- 增加与 BGP 事件/观测范围的空间邻近提示
- 增加与卫星覆盖或区域连通性的实验性提示
完成标准:
- 点击算力中心时,用户能看到“它和哪些基础设施相关”
- 信息表达以辅助判断为主,不做夸张推断
## Phase 3: Compute Center Observatory
目标:
- 把算力中心从 Earth 图层扩展成独立专题观测能力
工作项:
- 新增算力中心专题页
- 提供国家/厂商/类型/容量分布统计
- 支持列表、筛选、详情和历史快照
- 预留 AI brief / assessment 入口
完成标准:
- 算力中心不再只是 Earth 上的视觉点位
- 能作为独立业务上下文进入日常观察与研判
## Phase 4: Alerts And Assessment
目标:
- 在不滥造“假动态告警”的前提下,引入真正有价值的变化感知
候选方向:
- 新增大规模算力中心
- 既有中心容量显著变化
- 国家/区域集中度显著变化
- 高价值中心与关键网络基础设施关系变化
完成标准:
- 告警来自可解释的结构变化
- 不把静态数据硬做成噪声式实时事件流
## Implementation Notes
建议按下面顺序推进:
1. 先统一 GeoJSON 契约
2. 再做 Earth 独立模块和图层开关
3. 再补详情卡、图例和统计
4. 最后才做关系层和专题页
这样可以避免一开始把范围摊得过大。
## Non-Goals
第一阶段不建议做这些内容:
- 不复制 BGP 巡航模式到算力中心
- 不先做复杂实时 websocket 推送
- 不先引入独立 `compute_center_incident` 一类模型
- 不先做全量 AI 分析面板
原因是算力中心的第一需求是“被看清楚”,不是“被实时播报”。
但“被看清楚”不等于“只显示精确坐标对象”。
对于没有精确经纬度、但能推测到国家或区域级位置的算力中心,应优先以上图并标注估算状态的方式处理,而不是直接在地图上消失。
## Acceptance Checklist
- 后端存在统一的算力中心 GeoJSON 出口
- Earth 有独立算力图层模块,而不是散落在 `main.js`
- 页面上有清晰的算力开关、图例和统计
- `supercomputer` 和 `gpu_cluster` 在视觉和详情上都可区分
- 估算位置对象在地图和详情中都有明确状态提示
- 现有 BGP / 海缆 / 卫星功能无回归
- 代码结构上为后续专题页和关系分析留出了明确扩展点
## Summary
这项工作的本质不是“再多画几个点”。
它应该把算力中心从已有数据源,升级成与 BGP 同级的 Earth 观测主题:
- 有独立语义
- 有独立图层
- 有独立交互
- 有后续分析扩展能力
推荐先完成 Phase 1,把算力中心做成真正可用的 Earth 一级模块,再继续推进关系层和专题页。

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.35.1`
- `dev` 当前开发分支历史推导到:`0.36.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.36.0` | feature | `dev` | `pending` | Earth 新增统一算力中心图层与估算位置展示,继续收口拖拽交互,并补充 AI Provider 指纹与 WSL 局域网访问支撑 |
| `0.35.1` | bugfix | `dev` | `pending` | 收口 Earth 桌面 HUD 与移动端抽屉的统一统计绑定机制,修复态势统计在图层切换后的同步遗漏 |
| `0.35.0` | feature | `dev` | `pending` | Earth 移动端抽屉系统与悬浮卡片全面上线:手势驱动抽屉、点击物件弹出可拖动详情卡、单指旋转双指缩放地球 |
| `0.34.0` | feature | `dev` | `pending` | Earth 搜索面板正式接入,`planet.sh --allow-lan` 打通 Bun + Vite 局域网开放链路,并自动输出推荐访问地址与健康检查地址 |

View File

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

View File

@@ -70,6 +70,13 @@ body.earth-page {
overflow: hidden;
}
html.is-globe-dragging,
body.earth-page.is-globe-dragging,
body.earth-page.is-globe-dragging * {
user-select: none !important;
-webkit-user-select: none !important;
}
.earth-app {
position: relative;
width: 100vw;

View File

@@ -132,6 +132,16 @@
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="算力中心 compute centers">
<span class="material-symbols-rounded layer-row-icon">memory</span>
<div class="layer-row-copy">
<span class="layer-row-label">算力中心</span>
<span class="layer-row-meta">Compute Centers</span>
</div>
<button id="toggle-compute-centers" class="layer-row-toggle active" type="button" role="switch" aria-checked="true" title="切换算力中心显示">
<span class="layer-row-toggle-track"></span>
</button>
</div>
<div class="layer-row" data-layer-name="bgp观测 routing signals">
<span class="material-symbols-rounded layer-row-icon">hub</span>
<div class="layer-row-copy">
@@ -293,6 +303,10 @@
<span class="stat-num" id="satellite-count" data-earth-stat="satellite-count">—</span>
<span class="stat-label">在轨卫星</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="compute-center-count" data-earth-stat="compute-center-count">—</span>
<span class="stat-label">算力中心</span>
</div>
<div class="stat-cell">
<span class="stat-num" id="bgp-anomaly-count" data-earth-stat="bgp-anomaly-count">—</span>
<span class="stat-label">BGP 事件</span>
@@ -456,7 +470,7 @@
<div class="earth-mobile-page earth-mobile-page--search">
<div class="earth-mobile-page-intro">
<span class="earth-mobile-page-kicker">Object Search</span>
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星和 BGP 事件</span>
<span class="earth-mobile-page-summary">搜索海缆、登陆点、卫星、算力中心和 BGP 事件</span>
</div>
<div class="earth-mobile-search-shell">
<span class="material-symbols-rounded earth-mobile-search-icon" aria-hidden="true">search</span>
@@ -475,7 +489,7 @@
</div>
<div id="mobile-earth-search-meta" class="earth-mobile-search-meta">输入关键词以搜索当前地球对象</div>
<div id="mobile-earth-search-results" class="earth-mobile-search-results" role="listbox" aria-label="移动端搜索结果"></div>
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
<div id="mobile-earth-search-empty" class="earth-mobile-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
</div>
</section>
<section class="earth-mobile-drawer-slot earth-mobile-drawer-slot--situation" data-drawer-slot="situation">
@@ -497,6 +511,10 @@
<span id="mobile-satellite-count" class="earth-mobile-stat-num" data-earth-stat="satellite-count">—</span>
<span class="earth-mobile-stat-label">在轨卫星</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-compute-center-count" class="earth-mobile-stat-num" data-earth-stat="compute-center-count">—</span>
<span class="earth-mobile-stat-label">算力中心</span>
</div>
<div class="earth-mobile-stat-card">
<span id="mobile-bgp-anomaly-count" class="earth-mobile-stat-num" data-earth-stat="bgp-anomaly-count">—</span>
<span class="earth-mobile-stat-label">BGP 事件</span>
@@ -666,7 +684,7 @@
</div>
</div>
<div id="mobile-info-card-content" class="earth-mobile-detail-content">
<div class="earth-mobile-detail-empty">点击海缆、BGP 事件或卫星后在这里查看详情。</div>
<div class="earth-mobile-detail-empty">点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。</div>
</div>
</div>
</div>
@@ -697,7 +715,7 @@
inputmode="search"
autocomplete="off"
spellcheck="false"
placeholder="搜索海缆、登陆点、卫星、BGP 事件..."
placeholder="搜索海缆、登陆点、卫星、算力中心、BGP 事件..."
>
<button id="earth-search-clear" class="earth-search-clear hud-panel__action" type="button" aria-label="清除搜索" hidden>
<span class="material-symbols-rounded">close</span>
@@ -705,7 +723,7 @@
</div>
<div id="earth-search-meta" class="earth-search-meta">输入关键词以搜索当前地球对象</div>
<div id="earth-search-results" class="earth-search-results" role="listbox" aria-label="搜索结果"></div>
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、BGP 事件与观测站。</div>
<div id="earth-search-empty" class="earth-search-empty">支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,334 @@
import * as THREE from "three";
import { COMPUTE_CENTER_CONFIG, CONFIG, PATHS } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
const computeCenterGroup = new THREE.Group();
const computeCenterMarkers = [];
const textureCache = new Map();
const scratchMarkerWorldPosition = new THREE.Vector3();
let showComputeCenters = true;
let supercomputerCount = 0;
let gpuClusterCount = 0;
function createMarkerTexture(siteType, isEstimated = false) {
const textureKey = `${siteType}:${isEstimated ? "estimated" : "precise"}`;
if (textureCache.has(textureKey)) {
return textureCache.get(textureKey);
}
const color =
COMPUTE_CENTER_CONFIG.colors[siteType] ||
COMPUTE_CENTER_CONFIG.colors.gpu_cluster;
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
const centerX = 64;
const centerY = 64;
const baseFill = color;
function fillPath(draw, options = {}) {
const { fillStyle = color } = options;
context.save();
context.fillStyle = fillStyle;
context.beginPath();
draw();
context.fill();
context.restore();
}
context.clearRect(0, 0, 128, 128);
if (siteType === "supercomputer") {
fillPath(() => {
context.roundRect(40, 42, 48, 30, 7);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.roundRect(58, 74, 12, 8, 3);
context.roundRect(50, 84, 28, 5, 2.5);
}, {
fillStyle: baseFill,
});
} else {
fillPath(() => {
context.ellipse(centerX, 46, 18, 8, 0, 0, Math.PI * 2);
context.rect(46, 46, 36, 28);
context.ellipse(centerX, 74, 18, 8, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
fillPath(() => {
context.ellipse(centerX, 58, 12, 4.5, 0, 0, Math.PI * 2);
context.rect(52, 58, 24, 6);
context.ellipse(centerX, 64, 12, 4.5, 0, 0, Math.PI);
}, {
fillStyle: baseFill,
});
}
if (isEstimated) {
fillPath(() => {
context.arc(94, 36, 12, 0, Math.PI * 2);
}, {
fillStyle: "rgba(15,23,42,0.92)",
});
context.save();
context.fillStyle = "rgba(255,255,255,0.98)";
context.font = "bold 18px sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("?", 94, 36);
context.restore();
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
textureCache.set(textureKey, texture);
return texture;
}
function normalizeSiteType(siteType) {
return siteType === "supercomputer" ? "supercomputer" : "gpu_cluster";
}
function getBaseScale(siteType) {
return siteType === "supercomputer"
? COMPUTE_CENTER_CONFIG.marker.supercomputerScale
: COMPUTE_CENTER_CONFIG.marker.gpuClusterScale;
}
function getDistanceScale(marker, camera) {
if (!marker || !camera || COMPUTE_CENTER_CONFIG.sizeStabilization.enabled === false) {
return 1;
}
const distanceToCamera = camera.position.distanceTo(
marker.getWorldPosition(scratchMarkerWorldPosition),
);
const referenceDistance =
CONFIG.defaultCameraZ - CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset;
const referenceFovRad = (75 * Math.PI) / 180;
const cameraFovRad = ((camera.fov || 75) * Math.PI) / 180;
const worldPerPixel = distanceToCamera * Math.tan(cameraFovRad / 2);
const referenceWorldPerPixel = referenceDistance * Math.tan(referenceFovRad / 2);
const scale = worldPerPixel / referenceWorldPerPixel;
return THREE.MathUtils.clamp(
scale,
COMPUTE_CENTER_CONFIG.sizeStabilization.min,
COMPUTE_CENTER_CONFIG.sizeStabilization.max,
);
}
function clearGroup(group) {
for (let index = group.children.length - 1; index >= 0; index -= 1) {
const child = group.children[index];
child.material?.dispose?.();
group.remove(child);
}
}
function createComputeCenterMarker(feature) {
const props = feature?.properties || {};
const coordinates = feature?.geometry?.coordinates || [];
const longitude = Number(coordinates[0]);
const latitude = Number(coordinates[1]);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const siteType = normalizeSiteType(props.site_type);
const material = new THREE.SpriteMaterial({
map: createMarkerTexture(siteType, Boolean(props.is_estimated)),
transparent: true,
depthWrite: false,
opacity: COMPUTE_CENTER_CONFIG.marker.baseOpacity,
});
const marker = new THREE.Sprite(material);
const baseScale = getBaseScale(siteType);
marker.position.copy(
latLonToVector3(
latitude,
longitude,
CONFIG.earthRadius + COMPUTE_CENTER_CONFIG.altitudeOffset,
),
);
marker.scale.setScalar(baseScale);
marker.renderOrder = 8;
marker.visible = showComputeCenters;
marker.userData = {
...props,
latitude,
longitude,
site_type: siteType,
type: "compute_center",
baseScale,
state: "normal",
pulseOffset: Math.random() * Math.PI * 2,
};
computeCenterGroup.add(marker);
computeCenterMarkers.push(marker);
return marker;
}
export function formatComputeCenterTypeLabel(siteType) {
return siteType === "supercomputer" ? "超算中心" : "GPU 集群";
}
export function formatComputeCenterCapacity(markerData) {
const value = markerData?.capacity_value;
const unit = markerData?.capacity_unit;
if (value === null || value === undefined || value === "") return "-";
return `${value}${unit ? ` ${unit}` : ""}`;
}
export function formatComputeCenterUpdatedAt(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString("zh-CN", { hour12: false });
}
export function formatComputeCenterLocationPrecision(markerData) {
const precision = markerData?.location_precision;
if (precision === "precise") return "精确坐标";
if (precision === "estimated_site") return "估算位置(站点级)";
if (precision === "estimated_country") return "估算位置(国家级)";
return "位置未知";
}
export function getComputeCenterLegendItems() {
return [
{
label: "超算中心",
color: COMPUTE_CENTER_CONFIG.colors.supercomputer,
},
{
label: "GPU 集群",
color: COMPUTE_CENTER_CONFIG.colors.gpu_cluster,
},
];
}
export function getComputeCenterMarkers() {
return computeCenterMarkers;
}
export function getComputeCenterCount() {
return computeCenterMarkers.length;
}
export function getComputeCenterSupercomputerCount() {
return supercomputerCount;
}
export function getComputeCenterGPUClusterCount() {
return gpuClusterCount;
}
export function getComputeCenterStatusSummary() {
if (computeCenterMarkers.length === 0) return "暂无算力中心数据";
return `${supercomputerCount} 台超算 / ${gpuClusterCount} 个 GPU 集群`;
}
export function setComputeCenterMarkerState(marker, state = "normal") {
if (!marker || marker.userData?.type !== "compute_center") return;
marker.userData.state = state;
}
export function clearComputeCenterSelection() {
computeCenterMarkers.forEach((marker) => setComputeCenterMarkerState(marker, "normal"));
}
export function clearComputeCenterData(earth) {
computeCenterMarkers.length = 0;
supercomputerCount = 0;
gpuClusterCount = 0;
clearGroup(computeCenterGroup);
if (earth && computeCenterGroup.parent === earth) {
earth.remove(computeCenterGroup);
}
}
export function toggleComputeCenters(show) {
showComputeCenters = Boolean(show);
computeCenterGroup.visible = showComputeCenters;
computeCenterMarkers.forEach((marker) => {
marker.visible = showComputeCenters;
});
}
export function getShowComputeCenters() {
return showComputeCenters;
}
export async function loadComputeCenters(_scene, earth) {
const response = await fetch(PATHS.computeCentersApi);
if (!response.ok) {
throw new Error(`Compute centers HTTP ${response.status}`);
}
const payload = await response.json();
const features = Array.isArray(payload?.features) ? payload.features : [];
clearComputeCenterData(earth);
features
.slice(0, COMPUTE_CENTER_CONFIG.maxRenderedMarkers)
.forEach((feature) => {
const marker = createComputeCenterMarker(feature);
if (!marker) return;
if (marker.userData.site_type === "supercomputer") {
supercomputerCount += 1;
} else {
gpuClusterCount += 1;
}
});
if (earth && !computeCenterGroup.parent) {
earth.add(computeCenterGroup);
}
computeCenterGroup.visible = showComputeCenters;
return {
totalCount: computeCenterMarkers.length,
supercomputerCount,
gpuClusterCount,
summary: getComputeCenterStatusSummary(),
};
}
export function updateComputeCenterVisualState(lockedObjectType, lockedObject, camera) {
const hasFocus = lockedObjectType === "compute_center" && lockedObject;
const now = Date.now();
computeCenterMarkers.forEach((marker) => {
const isLocked = lockedObjectType === "compute_center" && lockedObject === marker;
const state = marker.userData?.state || "normal";
const pulse =
1 +
COMPUTE_CENTER_CONFIG.marker.pulseAmplitude *
Math.sin(now * COMPUTE_CENTER_CONFIG.marker.pulseSpeed + marker.userData.pulseOffset);
let opacity = COMPUTE_CENTER_CONFIG.marker.baseOpacity;
let scaleMultiplier = 1;
if (isLocked) {
opacity = 1;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.lockedScale * pulse;
} else if (state === "hover") {
opacity = 0.98;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.hoverScale;
} else if (hasFocus) {
opacity = COMPUTE_CENTER_CONFIG.marker.dimmedOpacity;
scaleMultiplier = COMPUTE_CENTER_CONFIG.marker.dimmedScale;
}
const distanceScale = getDistanceScale(marker, camera);
marker.material.opacity = showComputeCenters ? opacity : 0;
marker.scale.setScalar(marker.userData.baseScale * scaleMultiplier * distanceScale);
marker.visible = showComputeCenters;
});
}

View File

@@ -156,11 +156,38 @@ export const TERRAIN_CONFIG = {
export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
bgpCollectorsApi: '/api/v1/visualization/geo/bgp-collectors',
};
export const COMPUTE_CENTER_CONFIG = {
altitudeOffset: 0.48,
maxRenderedMarkers: 300,
marker: {
baseOpacity: 0.88,
supercomputerScale: 12,
gpuClusterScale: 12,
hoverScale: 1.16,
lockedScale: 1.22,
dimmedScale: 0.82,
dimmedOpacity: 0.34,
pulseSpeed: 0.0038,
pulseAmplitude: 0.03,
},
colors: {
supercomputer: "#fb923c",
gpu_cluster: "#60a5fa",
linked: "#f8fafc",
},
sizeStabilization: {
enabled: true,
min: 0.12,
max: 3.0,
},
};
// Cable colors mapping
export const CABLE_COLORS = {
'Americas II': 0xff4444,

View File

@@ -26,6 +26,11 @@ import {
} from "./satellites.js";
import { getShowCables } from "./cables.js";
import { toggleBGP, getShowBGP, getBGPCount } from "./bgp.js";
import {
toggleComputeCenters,
getShowComputeCenters,
getComputeCenterCount,
} from "./compute-centers.js";
import { ensureTVPanelReady, isTVPanelVisible, setTVPanelVisible } from "./tv.js";
import { createHUDPanel } from "./hud-panels.js";
import {
@@ -910,6 +915,22 @@ function setBGPLayerEnabled(button, enabled, { persist = true, silent = false }
return enabled;
}
function setComputeCentersLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
clearSelectionIfHiding(!enabled);
toggleComputeCenters(enabled);
setLayerButtonState(button, {
active: enabled,
tooltip: enabled ? "隐藏算力中心" : "显示算力中心",
});
setEarthStatValue("compute-center-count", `${getComputeCenterCount()} 个`);
syncMobileLayerCards();
if (persist) persistEarthSettings();
if (!silent) {
showStatusMessage(enabled ? "算力中心已显示" : "算力中心已隐藏", "info");
}
return enabled;
}
function setTrailsLayerEnabled(button, enabled, { persist = true, silent = false } = {}) {
toggleTrails(enabled);
setLayerButtonState(button, {
@@ -1017,6 +1038,22 @@ function getBuiltinLayerDefinitions() {
setVisible: (visible, options = {}) =>
setCablesLayerEnabled(getLayerButton("cables"), visible, options),
},
{
id: "computeCenters",
buttonId: "toggle-compute-centers",
icon: "memory",
label: "算力中心",
meta: "Compute Centers",
keywords: "算力中心 compute centers gpu 超算",
defaultActive: true,
startupPriority: 35,
startupMode: "preload",
startupLabel: "算力中心",
startupMessage: "正在加载算力中心...",
getVisible: () => getShowComputeCenters(),
setVisible: (visible, options = {}) =>
setComputeCentersLayerEnabled(getLayerButton("computeCenters"), visible, options),
},
{
id: "bgp",
buttonId: "toggle-bgp",

View File

@@ -274,15 +274,22 @@ const CARD_CONFIG = {
},
supercomputer: {
icon: '🖥️',
title: '超算详情',
title: '超算中心详情',
className: 'supercomputer',
fields: [
{ key: 'name', label: '名称' },
{ key: 'site_type_label', label: '类型' },
{ key: 'rank', label: '排名' },
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
{ key: 'capacity', label: '实测算力' },
{ key: 'vendor', label: '厂商' },
{ key: 'operator', label: '运营方' },
{ key: 'cores', label: '核心数' },
{ key: 'power', label: '功耗', unit: 'kW' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' }
{ key: 'city', label: '城市' },
{ key: 'location_precision_label', label: '位置精度' },
{ key: 'source', label: '来源' },
{ key: 'updated_at', label: '更新时间' }
]
},
gpu_cluster: {
@@ -291,8 +298,17 @@ const CARD_CONFIG = {
className: 'gpu_cluster',
fields: [
{ key: 'name', label: '名称' },
{ key: 'site_type_label', label: '类型' },
{ key: 'capacity', label: '估算算力' },
{ key: 'gpu_count', label: 'GPU 数量' },
{ key: 'gpu_type', label: 'GPU 型号' },
{ key: 'vendor', label: '芯片/平台' },
{ key: 'operator', label: '运营方' },
{ key: 'country', label: '国家' },
{ key: 'city', label: '城市' }
{ key: 'city', label: '城市' },
{ key: 'location_precision_label', label: '位置精度' },
{ key: 'source', label: '来源' },
{ key: 'updated_at', label: '更新时间' }
]
}
};
@@ -368,9 +384,11 @@ function setupInfoCardDrag(panel) {
handle.setPointerCapture?.(event.pointerId);
});
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', stopDragging);
window.addEventListener('pointercancel', stopDragging);
// Listen in capture phase so card-level stopPropagation used to shield the
// globe canvas does not swallow the drag stream before we can reposition.
window.addEventListener('pointermove', onMove, { passive: false, capture: true });
window.addEventListener('pointerup', stopDragging, { capture: true });
window.addEventListener('pointercancel', stopDragging, { capture: true });
handle.addEventListener('lostpointercapture', stopDragging);
}

View File

@@ -14,6 +14,10 @@ import {
loadBGPAnomalies,
toggleBGP,
} from "./bgp.js";
import {
loadComputeCenters,
toggleComputeCenters,
} from "./compute-centers.js";
/**
* Layer startup task registry.
@@ -70,6 +74,7 @@ function registerBuiltinLayerStartupTasks() {
startupTaskRegistry.clear();
registerCableStartupTask();
registerSatelliteStartupTask();
registerComputeCenterStartupTask();
registerBGPStartupTask();
}
@@ -171,4 +176,26 @@ function registerBGPStartupTask() {
});
}
function registerComputeCenterStartupTask() {
registerLayerStartupTask("computeCenters", (context) => async (layer) => {
context.setLoadingMessage(
resolveStartupMessage(layer, "load", "正在加载算力中心..."),
);
await context.yieldFrame(12);
try {
const computeCenterResult = await loadComputeCenters(context.scene, context.earth);
if (!context.isCancelled()) {
toggleComputeCenters(context.getShowComputeCenters());
context.updateComputeCenterHud(computeCenterResult);
context.setLegendItems("computeCenters", context.getComputeCenterLegendItems());
context.refreshLegend();
}
} catch (error) {
context.reportError(layer?.startupLabel || layer?.label || "算力中心", error);
}
if (context.isCancelled()) return;
await context.yieldFrame(16);
});
}
registerBuiltinLayerStartupTasks();

View File

@@ -1,9 +1,10 @@
import { createHUDPanel } from "./hud-panels.js";
const LEGEND_MODES = {
cables: { title: "海缆" },
satellites: { title: "卫星" },
bgp: { title: "BGP" },
cables: { title: "海缆" },
satellites: { title: "卫星" },
computeCenters: { title: "算力" },
bgp: { title: "BGP" },
};
let currentLegendMode = "cables";
@@ -11,6 +12,7 @@ let legendPanel = null;
let legendItemsByMode = {
cables: [],
satellites: [],
computeCenters: [],
bgp: [],
};

View File

@@ -128,6 +128,22 @@ import {
showBGPEventOverlay,
showBGPCollectorCoverageOverlay,
} from "./bgp.js";
import {
clearComputeCenterData,
clearComputeCenterSelection,
formatComputeCenterCapacity,
formatComputeCenterLocationPrecision,
formatComputeCenterTypeLabel,
formatComputeCenterUpdatedAt,
getComputeCenterCount,
getComputeCenterLegendItems,
getComputeCenterMarkers,
getShowComputeCenters,
loadComputeCenters,
setComputeCenterMarkerState,
toggleComputeCenters,
updateComputeCenterVisualState,
} from "./compute-centers.js";
import {
setupControls,
getAutoRotate,
@@ -177,6 +193,7 @@ let targetRotation = { x: 0, y: 0 };
let inertialVelocity = { x: 0, y: 0 };
let hoveredCable = null;
let hoveredBGP = null;
let hoveredComputeCenter = null;
let hoveredSatellite = null;
let hoveredSatelliteIndex = null;
let lockedSatellite = null;
@@ -222,6 +239,8 @@ const scratchCableCenter = new THREE.Vector3();
const scratchCableDirection = new THREE.Vector3();
const scratchBGPDirection = new THREE.Vector3();
const scratchBGPWorldPosition = new THREE.Vector3();
const scratchComputeCenterDirection = new THREE.Vector3();
const scratchComputeCenterWorldPosition = new THREE.Vector3();
const scratchViewCenterWorld = new THREE.Vector3();
const cleanupFns = [];
@@ -234,6 +253,7 @@ const TOOLTIP_CURSOR_OFFSET = 14; // px offset from cursor for hover tooltips
const TOOLTIP_COORDS_OFFSET = 10; // px offset for earth-coordinate tooltip
const RELATED_SATELLITE_HIGHLIGHT_COLOR = "#7dd3fc";
const DRAG_POINTER_THRESHOLD_PX = 8;
const GLOBE_DRAGGING_CLASS = "is-globe-dragging";
const HUD_INTERACTIVE_SELECTORS = [
".earth-left-column",
".earth-left-column *",
@@ -276,6 +296,17 @@ function isEventOnHud(event) {
return HUD_INTERACTIVE_SELECTORS.some((selector) => target.closest(selector));
}
function clearDocumentSelection() {
const selection = window.getSelection?.();
if (!selection || selection.rangeCount === 0) return;
selection.removeAllRanges();
}
function setGlobeDraggingUiState(active) {
document.body.classList.toggle(GLOBE_DRAGGING_CLASS, active);
document.documentElement.classList.toggle(GLOBE_DRAGGING_CLASS, active);
}
function getDragRotationFactor() {
const zoom = Math.max(getZoomLevel(), 0.01);
const scale = THREE.MathUtils.clamp(
@@ -338,6 +369,7 @@ function disposeSceneObject(object) {
function clearRuntimeSelection() {
hoveredCable = null;
hoveredBGP = null;
hoveredComputeCenter = null;
hoveredSatellite = null;
hoveredSatelliteIndex = null;
lockedObject = null;
@@ -353,6 +385,7 @@ export function clearLockedObject() {
clearAllCableStates();
clearCableSelection();
clearBGPSelection();
clearComputeCenterSelection();
clearRelatedSatelliteHighlights();
setSatelliteRingState(null, "none", null);
clearRuntimeSelection();
@@ -388,6 +421,14 @@ function isSameBGPMarker(marker1, marker2) {
return false;
}
function isSameComputeCenter(marker1, marker2) {
if (!marker1 || !marker2) return false;
if (marker1.userData?.type !== "compute_center" || marker2.userData?.type !== "compute_center") {
return false;
}
return marker1.userData?.id === marker2.userData?.id;
}
function getBGPCollectorMarkerByName(collector) {
return getBGPCollectorMarkers().find(
(marker) => marker.userData?.collector === collector,
@@ -407,9 +448,19 @@ function resetTransientBGPStates() {
});
}
function resetTransientComputeCenterStates() {
getComputeCenterMarkers().forEach((marker) => {
if (marker !== lockedObject) {
setComputeCenterMarkerState(marker, "normal");
}
});
}
function clearTransientHoverState() {
resetTransientBGPStates();
resetTransientComputeCenterStates();
hoveredBGP = null;
hoveredComputeCenter = null;
if (hoveredCable && !isSameCable(hoveredCable, lockedObject)) {
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
@@ -445,6 +496,18 @@ function applyBGPHoverState(marker) {
}
}
function applyComputeCenterHoverState(marker) {
resetTransientComputeCenterStates();
if (!marker) {
hoveredComputeCenter = null;
return;
}
hoveredComputeCenter = marker;
if (marker !== lockedObject) {
setComputeCenterMarkerState(marker, "hover");
}
}
function getPrimaryBGPHoverTarget(bgpAnomalyIntersects, bgpCollectorIntersects) {
if (bgpAnomalyIntersects.length > 0) {
return bgpAnomalyIntersects[0].object;
@@ -525,6 +588,40 @@ function getSatelliteBriefHtml(props) {
return `<strong>${name}</strong>${id ? `<br>${id}` : ""}`;
}
function showComputeCenterInfo(marker, coords) {
const siteType = marker.userData?.site_type === "supercomputer"
? "supercomputer"
: "gpu_cluster";
setLegendMode("computeCenters");
showInfoCard(siteType, {
name: marker.userData?.name || "-",
site_type_label: formatComputeCenterTypeLabel(siteType),
rank: marker.userData?.rank ?? "-",
capacity: formatComputeCenterCapacity(marker.userData),
vendor: marker.userData?.vendor || "-",
operator: marker.userData?.operator || "-",
gpu_count: marker.userData?.gpu_count ?? "-",
gpu_type: marker.userData?.gpu_type || "-",
cores: marker.userData?.cores ?? "-",
power: marker.userData?.power ?? "-",
country: marker.userData?.country || "-",
city: marker.userData?.city || "-",
location_precision_label: formatComputeCenterLocationPrecision(marker.userData),
source: marker.userData?.source || "-",
updated_at: formatComputeCenterUpdatedAt(marker.userData?.updated_at),
}, coords);
}
function getComputeCenterBriefHtml(marker) {
const name = marker.userData?.name || "算力中心";
const type = formatComputeCenterTypeLabel(marker.userData?.site_type);
const location = [marker.userData?.city, marker.userData?.country]
.filter(Boolean)
.join(", ");
const precision = marker.userData?.is_estimated ? " · 估算位置" : "";
return `<strong>${name}</strong><br>${type}${location ? ` · ${location}` : ""}${precision}`;
}
function showBGPInfo(marker, coords) {
setLegendMode("bgp");
const impactedRegions =
@@ -700,6 +797,13 @@ function getBGPFocusCoords(marker) {
return { lat, lon };
}
function getComputeCenterFocusCoords(marker) {
const lat = marker?.userData?.latitude;
const lon = marker?.userData?.longitude;
if (typeof lat !== "number" || typeof lon !== "number") return null;
return { lat, lon };
}
async function focusSearchTarget(coords, zoom = Math.max(getZoomLevel(), 1.12)) {
if (!coords || !camera) return;
await focusEarthView(camera, {
@@ -847,6 +951,29 @@ async function focusSearchBGPMarker(marker) {
}
}
async function focusSearchComputeCenter(marker) {
if (!getShowComputeCenters()) {
toggleComputeCenters(true);
}
interruptCruisePresentation({ resetLoop: true });
clearLockedObject();
setAutoRotate(false);
const coords = getComputeCenterFocusCoords(marker);
if (coords) {
await focusSearchTarget(coords, Math.max(getZoomLevel(), 1.16));
}
setComputeCenterMarkerState(marker, "locked");
lockedObject = marker;
lockedObjectType = "compute_center";
showComputeCenterInfo(marker, getSearchCardCoords());
showStatusMessage(
`已定位算力中心:${marker.userData?.name || "未知节点"}`,
"info",
);
}
function resolveEarthSearchResults(query) {
const results = [];
const normalizedQuery = query.trim().toLowerCase();
@@ -985,6 +1112,34 @@ function resolveEarthSearchResults(query) {
});
});
getComputeCenterMarkers().forEach((marker) => {
const score = computeSearchScore(
normalizedQuery,
marker.userData?.name,
marker.userData?.country,
marker.userData?.city,
marker.userData?.operator,
marker.userData?.vendor,
marker.userData?.site_type,
"算力中心 compute center gpu 超算",
);
if (score < 0) return;
results.push({
id: `compute:${marker.userData?.id || marker.uuid}`,
kind: "compute_center",
icon: "memory",
typeLabel: "算力中心",
title: marker.userData?.name || "未知算力中心",
subtitle: [
formatComputeCenterTypeLabel(marker.userData?.site_type),
marker.userData?.city,
marker.userData?.country,
].filter(Boolean).join(" · ") || "算力基础设施",
score,
entity: marker,
});
});
return results
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
@@ -1010,6 +1165,10 @@ async function handleSearchSelection(result) {
}
if (result.kind === "bgp" || result.kind === "bgp_collector") {
await focusSearchBGPMarker(result.entity);
return;
}
if (result.kind === "compute_center") {
await focusSearchComputeCenter(result.entity);
}
}
@@ -1023,6 +1182,19 @@ function getBGPStatusText(bgpResult) {
return "当前无活跃事件";
}
function updateComputeCenterHud(computeCenterResult) {
const computeBtn = document.getElementById("toggle-compute-centers");
if (computeBtn) {
computeBtn.classList.add("active");
const tooltip = computeBtn.querySelector(".earth-toolbar-tooltip");
if (tooltip) {
tooltip.textContent = "隐藏算力中心";
}
}
setEarthStatValue("compute-center-count", `${computeCenterResult.totalCount} 个`);
}
function updateBGPHud(bgpResult) {
const bgpBtn = document.getElementById("toggle-bgp");
if (bgpBtn) {
@@ -1530,6 +1702,7 @@ function updateStatsSummary() {
updateEarthStats({
cableCount: getCableLines().length,
landingPointCount: getLandingPoints().length,
computeCenterCount: `${getComputeCenterCount()} 个`,
bgpAnomalyCount: `${getBGPCount()} 条`,
bgpCollectorCount: `${getBGPCollectorCount()} 个`,
bgpStatusSummary: getBGPStatusSummary(),
@@ -1605,6 +1778,7 @@ export function init() {
initLegend();
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
const earthObj = createEarth(scene);
applyImmediateView(earthObj, camera);
@@ -1762,6 +1936,7 @@ async function loadData() {
clearEarthTexture();
clearBGPData(earth);
clearCableData(earth);
clearComputeCenterData(earth);
clearSatelliteData();
setLoadingMessage("正在初始化...");
@@ -1789,9 +1964,12 @@ async function loadData() {
yieldFrame,
refreshLegend,
setLegendItems,
getComputeCenterLegendItems,
updateCableToggleUi,
updateSatelliteToggleUi,
updateComputeCenterHud,
updateBGPHud,
getShowComputeCenters,
getShowBGP,
getInitialSatelliteLoadLimit,
shouldHydrateFullSatelliteSet,
@@ -1838,6 +2016,7 @@ async function loadData() {
updateSatelliteToggleUi(satellitesEnabled);
setLegendItems("cables", getCableLegendItems());
setLegendItems("satellites", getSatelliteLegendItems());
setLegendItems("computeCenters", getComputeCenterLegendItems());
setLegendItems("bgp", getBGPLegendItems());
refreshLegend();
setLoading(false);
@@ -2055,6 +2234,25 @@ function getFrontFacingBGPMarkers(markers) {
});
}
function getFrontFacingComputeCenterMarkers(markers) {
const earth = getEarth();
if (!earth) return markers;
scratchCameraToEarth.subVectors(camera.position, earth.position).normalize();
return markers.filter((marker) => {
scratchComputeCenterWorldPosition.copy(marker.position);
marker.parent?.localToWorld(scratchComputeCenterWorldPosition);
scratchComputeCenterDirection
.subVectors(scratchComputeCenterWorldPosition, earth.position)
.normalize();
return (
scratchCameraToEarth.dot(scratchComputeCenterDirection) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
});
}
function onMouseMove(event) {
const earth = getEarth();
if (!earth) return;
@@ -2112,6 +2310,12 @@ function onMouseMove(event) {
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
: [];
const frontFacingComputeCenterMarkers = getFrontFacingComputeCenterMarkers(
getComputeCenterMarkers(),
);
const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(frontFacingComputeCenterMarkers)
: [];
let hoveredSat = null;
let hoveredSatIndexFromIntersect = null;
@@ -2138,6 +2342,16 @@ function onMouseMove(event) {
clearTransientHoverState();
}
const hoveredComputeCenterMarker =
computeCenterIntersects.length > 0 ? computeCenterIntersects[0].object : null;
if (
hoveredComputeCenter &&
!isSameComputeCenter(hoveredComputeCenter, hoveredComputeCenterMarker)
) {
clearTransientHoverState();
}
if (
hoveredCable &&
(!cableIntersects.length ||
@@ -2168,6 +2382,18 @@ function onMouseMove(event) {
showTooltip(event.clientX + TOOLTIP_CURSOR_OFFSET, event.clientY + TOOLTIP_CURSOR_OFFSET, getBGPCollectorBriefHtml(hoveredBGPMarker));
}
objectTooltipShown = true;
} else if (
hoveredComputeCenterMarker &&
getShowComputeCenters() &&
lockedObjectType !== "compute_center"
) {
applyComputeCenterHoverState(hoveredComputeCenterMarker);
showTooltip(
event.clientX + TOOLTIP_CURSOR_OFFSET,
event.clientY + TOOLTIP_CURSOR_OFFSET,
getComputeCenterBriefHtml(hoveredComputeCenterMarker),
);
objectTooltipShown = true;
} else if (cableIntersects.length > 0 && getShowCables()) {
const cable = cableIntersects[0].object;
hoveredCable = cable;
@@ -2195,8 +2421,11 @@ function onMouseMove(event) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "bgp_collector" && lockedObject) {
applyBGPHoverState(lockedObject);
} else if (lockedObjectType === "compute_center" && lockedObject) {
applyComputeCenterHoverState(lockedObject);
} else if (!lockedObjectType && !isCruisePresentationPinned()) {
resetTransientBGPStates();
resetTransientComputeCenterStates();
hideInfoCard();
}
@@ -2247,12 +2476,16 @@ function onMouseDown(event) {
y: earth.rotation.y,
};
}
clearDocumentSelection();
setGlobeDraggingUiState(true);
document.getElementById("container")?.classList.add("dragging");
hideTooltip();
}
function onMouseUp() {
isDragging = false;
setGlobeDraggingUiState(false);
clearDocumentSelection();
document.getElementById("container")?.classList.remove("dragging");
}
@@ -2260,6 +2493,9 @@ function onPointerDown(event) {
if (isEventOnHud(event)) return;
if (event.pointerType !== "touch" && event.button !== 0) return;
renderer?.domElement?.setPointerCapture?.(event.pointerId);
clearDocumentSelection();
if (event.pointerType === "touch") {
activeTouchPoints.set(event.pointerId, {
clientX: event.clientX,
@@ -2286,6 +2522,10 @@ function onPointerDown(event) {
}
function onPointerMove(event) {
if (activeDragPointerId === event.pointerId && isDragging) {
clearDocumentSelection();
}
if (event.pointerType === "touch") {
if (activeTouchPoints.has(event.pointerId)) {
activeTouchPoints.set(event.pointerId, {
@@ -2344,6 +2584,7 @@ function onPointerUp(event) {
suppressNextClick = true;
isLongDrag = true;
}
renderer?.domElement?.releasePointerCapture?.(event.pointerId);
activeDragPointerId = null;
pointerDragDistance = 0;
onMouseUp();
@@ -2380,6 +2621,11 @@ function onClick(event) {
const bgpCollectorIntersects = getShowBGP()
? interactionRaycaster.intersectObjects(frontFacingBGPCollectorMarkers)
: [];
const computeCenterIntersects = getShowComputeCenters()
? interactionRaycaster.intersectObjects(
getFrontFacingComputeCenterMarkers(getComputeCenterMarkers()),
)
: [];
const satIntersects = getShowSatellites()
? interactionRaycaster.intersectObject(getSatellitePoints())
: [];
@@ -2387,6 +2633,9 @@ function onClick(event) {
const clickedBGPMarker = getShowBGP()
? getPrimaryBGPClickTarget(event, bgpAnomalyIntersects, bgpCollectorIntersects)
: null;
const clickedComputeCenterMarker = computeCenterIntersects.length > 0
? computeCenterIntersects[0].object
: null;
if (clickedBGPMarker?.userData?.type === "bgp") {
interruptCruisePresentation();
@@ -2437,6 +2686,23 @@ function onClick(event) {
return;
}
if (clickedComputeCenterMarker?.userData?.type === "compute_center") {
interruptCruisePresentation();
clearLockedObject();
const clickedMarker = clickedComputeCenterMarker;
setComputeCenterMarkerState(clickedMarker, "locked");
lockedObject = clickedMarker;
lockedObjectType = "compute_center";
setAutoRotate(false);
showComputeCenterInfo(clickedMarker, { x: event.clientX, y: event.clientY });
showStatusMessage(
`已选择算力中心: ${clickedMarker.userData?.name || "未知节点"}`,
"info",
);
return;
}
if (cableIntersects.length > 0 && getShowCables()) {
interruptCruisePresentation();
clearLockedObject();
@@ -2594,6 +2860,7 @@ function animate() {
? cruiseSequencer?.getCurrentItem() ?? null
: null;
updateBGPVisualState(lockedObjectType, lockedObject, camera, activeCruiseMarker);
updateComputeCenterVisualState(lockedObjectType, lockedObject, camera);
if (lockedObjectType === "cable" && lockedObject) {
applyLandingPointVisualState(lockedObject.userData.name, false, camera);
@@ -2641,6 +2908,8 @@ export function destroy() {
destroyed = true;
currentLoadToken += 1;
isDataLoading = false;
setGlobeDraggingUiState(false);
clearDocumentSelection();
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
@@ -2656,6 +2925,7 @@ export function destroy() {
clearLockedObject();
clearCableData(getEarth());
clearBGPData(getEarth());
clearComputeCenterData(getEarth());
resetSatelliteState();
clearUiState();
disposeCelestialLayer();

View File

@@ -48,7 +48,7 @@ function updateEmptyState(query) {
const { empty } = getElements();
if (!empty) return;
if (!query) {
empty.textContent = "支持搜索海缆、登陆点、卫星、BGP 事件与观测站。";
empty.textContent = "支持搜索海缆、登陆点、卫星、算力中心、BGP 事件与观测站。";
return;
}
empty.textContent = "未找到匹配对象,可尝试名称、地点、NORAD、ASN、前缀等关键词。";

View File

@@ -187,6 +187,7 @@ export function updateZoomDisplay(zoomLevel, distance) {
export function updateEarthStats(stats) {
setEarthStatValue("cable-count", String(stats.cableCount || 0));
setEarthStatValue("landing-point-count", String(stats.landingPointCount || 0));
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
setEarthStatValue("bgp-status-summary", stats.bgpStatusSummary || "-");

View File

@@ -520,12 +520,13 @@ print_database_failure_diagnostics() {
compute_ai_provider_build_fingerprint() {
(
cd "$SCRIPT_DIR" || exit 1
tar -cf - \
aiprovider \
pyproject.toml \
uv.lock \
docker-compose.yml \
docker-compose.simple.yml 2>/dev/null
{
tar -cf - \
aiprovider \
docker-compose.yml \
docker-compose.simple.yml 2>/dev/null
python3 "$SCRIPT_DIR/scripts/compute_aiprovider_dependency_fingerprint.py"
}
) | sha256sum | awk '{print $1}'
}

View File

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

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
from typing import Any
IMPORT_TO_DISTRIBUTION = {
"pydantic_settings": "pydantic-settings",
}
# Some runtime dependencies are referenced indirectly:
# - uvicorn is launched by the container command rather than imported.
# - python-dotenv is used by pydantic-settings when loading the local .env file.
EXTRA_RUNTIME_DISTRIBUTIONS = {
"python-dotenv",
"uvicorn",
}
def normalize_name(value: str) -> str:
return value.strip().lower().replace("_", "-").replace(".", "-")
def extract_table(text: str, table_name: str) -> str:
pattern = re.compile(
rf"(?ms)^\[{re.escape(table_name)}\]\s*$\n(.*?)(?=^\[[^\]]+\]\s*$|\Z)"
)
match = pattern.search(text)
return match.group(1) if match else ""
def extract_string_value(text: str, key: str) -> str | None:
match = re.search(rf'(?m)^{re.escape(key)}\s*=\s*"([^"]+)"\s*$', text)
return match.group(1) if match else None
def extract_string_list_value(text: str, key: str) -> list[str]:
match = re.search(rf"(?ms)^{re.escape(key)}\s*=\s*\[(.*?)\]\s*$", text)
if not match:
return []
return re.findall(r'"([^"]+)"', match.group(1))
def project_dependency_map(pyproject_text: str) -> tuple[str | None, dict[str, str]]:
project_block = extract_table(pyproject_text, "project")
requires_python = extract_string_value(project_block, "requires-python")
dependencies = extract_string_list_value(project_block, "dependencies")
mapping: dict[str, str] = {}
for spec in dependencies:
name = spec.split(";", 1)[0].strip()
for marker in ("<", ">", "=", "!", "~"):
if marker in name:
name = name.split(marker, 1)[0].strip()
if "[" in name:
name = name.split("[", 1)[0].strip()
mapping[normalize_name(name)] = spec.strip()
return requires_python, mapping
def collect_runtime_imports(aiprovider_dir: Path) -> set[str]:
imports: set[str] = set()
stdlib = set(sys.stdlib_module_names)
for path in sorted(aiprovider_dir.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.add(alias.name.split(".", 1)[0])
elif isinstance(node, ast.ImportFrom) and node.module:
imports.add(node.module.split(".", 1)[0])
return {
name
for name in imports
if name not in stdlib and name != "aiprovider"
}
def resolve_relevant_root_dependencies(
pyproject_deps: dict[str, str],
imported_modules: set[str],
) -> dict[str, str]:
relevant_names: set[str] = set(EXTRA_RUNTIME_DISTRIBUTIONS)
for module_name in imported_modules:
mapped_name = IMPORT_TO_DISTRIBUTION.get(module_name, module_name)
normalized_name = normalize_name(mapped_name)
if normalized_name in pyproject_deps:
relevant_names.add(normalized_name)
return {
name: pyproject_deps[name]
for name in sorted(relevant_names)
if name in pyproject_deps
}
def lock_metadata(lock_text: str) -> dict[str, str | None]:
return {
"version": extract_string_value(lock_text, "version") or re.search(r"(?m)^version\s*=\s*(\d+)\s*$", lock_text).group(1),
"revision": extract_string_value(lock_text, "revision") or re.search(r"(?m)^revision\s*=\s*(\d+)\s*$", lock_text).group(1),
"requires_python": extract_string_value(lock_text, "requires-python"),
}
def lock_package_map(lock_text: str) -> dict[str, dict[str, Any]]:
mapping: dict[str, dict[str, Any]] = {}
sections = re.split(r"(?m)^\[\[package\]\]\s*$\n?", lock_text)
for section in sections[1:]:
raw_section = section.strip()
name = extract_string_value(raw_section, "name")
if not name:
continue
version = extract_string_value(raw_section, "version")
dependency_names = [
normalize_name(dep_name)
for dep_name in re.findall(r'\{\s*name\s*=\s*"([^"]+)"', raw_section)
]
mapping[normalize_name(name)] = {
"name": name,
"version": version,
"dependencies": dependency_names,
"raw": raw_section,
}
return mapping
def dependency_closure(
lock_packages: dict[str, dict[str, Any]],
root_dependencies: dict[str, str],
) -> list[dict[str, Any]]:
pending = list(root_dependencies.keys())
visited: set[str] = set()
resolved: list[dict[str, Any]] = []
while pending:
current = pending.pop()
if current in visited:
continue
visited.add(current)
package = lock_packages.get(current)
if package is None:
continue
resolved.append(package)
for dep_name in package.get("dependencies", []):
pending.append(dep_name)
resolved.sort(key=lambda pkg: (normalize_name(pkg["name"]), pkg.get("version", "")))
return resolved
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
pyproject_path = repo_root / "pyproject.toml"
uv_lock_path = repo_root / "uv.lock"
aiprovider_dir = repo_root / "aiprovider"
pyproject_text = pyproject_path.read_text(encoding="utf-8")
uv_lock_text = uv_lock_path.read_text(encoding="utf-8")
requires_python, pyproject_deps = project_dependency_map(pyproject_text)
imported_modules = collect_runtime_imports(aiprovider_dir)
relevant_roots = resolve_relevant_root_dependencies(pyproject_deps, imported_modules)
relevant_packages = dependency_closure(lock_package_map(uv_lock_text), relevant_roots)
fingerprint_payload = {
"requires_python": requires_python,
"relevant_root_dependencies": relevant_roots,
"lock": {**lock_metadata(uv_lock_text), "packages": relevant_packages},
}
print(json.dumps(fingerprint_payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())