From abe04030fb156f13a5fa9c6a0f294b3ca61b7f8c Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 22 Apr 2026 23:42:10 +0800 Subject: [PATCH] release: bump version to 0.36.0 --- README.md | 114 ++++++ VERSION | 2 +- backend/app/api/v1/visualization.py | 257 ++++++++++++++ .../test_visualization_compute_centers.py | 217 ++++++++++++ docs/CHANGELOG.md | 18 + docs/plans/README.md | 1 + .../earth-compute-center-bgp-style-plan.md | 312 ++++++++++++++++ docs/version-history.md | 3 +- frontend/package.json | 2 +- frontend/public/earth/css/base.css | 7 + frontend/public/earth/index.html | 28 +- frontend/public/earth/js/compute-centers.js | 334 ++++++++++++++++++ frontend/public/earth/js/constants.js | 27 ++ frontend/public/earth/js/controls.js | 37 ++ frontend/public/earth/js/info-card.js | 34 +- .../public/earth/js/layer-startup-tasks.js | 27 ++ frontend/public/earth/js/legend.js | 8 +- frontend/public/earth/js/main.js | 270 ++++++++++++++ frontend/public/earth/js/search.js | 2 +- frontend/public/earth/js/ui.js | 1 + planet.sh | 13 +- pyproject.toml | 2 +- ...mpute_aiprovider_dependency_fingerprint.py | 186 ++++++++++ 23 files changed, 1875 insertions(+), 27 deletions(-) create mode 100644 backend/tests/test_visualization_compute_centers.py create mode 100644 docs/plans/earth-compute-center-bgp-style-plan.md create mode 100644 frontend/public/earth/js/compute-centers.js create mode 100644 scripts/compute_aiprovider_dependency_fingerprint.py diff --git a/README.md b/README.md index a91acf37..141629a4 100644 --- a/README.md +++ b/README.md @@ -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://:3000/earth` +- `http://: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://:3000/earth` + ## 启动容错参数 `planet.sh` 现在为依赖安装、数据库、AI Provider 启动加入了有限次重试,并会在数据库与 `aiprovider` 启动后额外等待 Docker healthcheck。 diff --git a/VERSION b/VERSION index 731b95d7..93d4c1ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.35.1 +0.36.0 diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 23487b65..ac6198a1 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -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), diff --git a/backend/tests/test_visualization_compute_centers.py b/backend/tests/test_visualization_compute_centers.py new file mode 100644 index 00000000..0d0adb99 --- /dev/null +++ b/backend/tests/test_visualization_compute_centers.py @@ -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() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 22e9ae1e..d6a79592 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,24 @@ This project follows the repository versioning rule: - `improvement` -> `+0.0.1`(bugfix + 小功能混合) - `bugfix` -> `+0.0.1` +## [0.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 diff --git a/docs/plans/README.md b/docs/plans/README.md index 0aa0aaed..cd81e9bf 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -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) diff --git a/docs/plans/earth-compute-center-bgp-style-plan.md b/docs/plans/earth-compute-center-bgp-style-plan.md new file mode 100644 index 00000000..56913e3b --- /dev/null +++ b/docs/plans/earth-compute-center-bgp-style-plan.md @@ -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 一级模块,再继续推进关系层和专题页。 diff --git a/docs/version-history.md b/docs/version-history.md index 806a7494..47477467 100644 --- a/docs/version-history.md +++ b/docs/version-history.md @@ -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 局域网开放链路,并自动输出推荐访问地址与健康检查地址 | diff --git a/frontend/package.json b/frontend/package.json index aa8f86b9..13c0249c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "planet-frontend", - "version": "0.35.1", + "version": "0.36.0", "private": true, "packageManager": "bun@1", "dependencies": { diff --git a/frontend/public/earth/css/base.css b/frontend/public/earth/css/base.css index ee856560..3330f6f2 100644 --- a/frontend/public/earth/css/base.css +++ b/frontend/public/earth/css/base.css @@ -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; diff --git a/frontend/public/earth/index.html b/frontend/public/earth/index.html index 1fba5009..6edeeafb 100644 --- a/frontend/public/earth/index.html +++ b/frontend/public/earth/index.html @@ -132,6 +132,16 @@ +
+ memory +
+ 算力中心 + Compute Centers +
+ +
hub
@@ -293,6 +303,10 @@ 在轨卫星
+
+ + 算力中心 +
BGP 事件 @@ -456,7 +470,7 @@
@@ -497,6 +511,10 @@ 在轨卫星
+
+ + 算力中心 +
BGP 事件 @@ -666,7 +684,7 @@
-
点击海缆、BGP 事件或卫星后在这里查看详情。
+
点击海缆、算力中心、BGP 事件或卫星后在这里查看详情。
@@ -697,7 +715,7 @@ inputmode="search" autocomplete="off" spellcheck="false" - placeholder="搜索海缆、登陆点、卫星、BGP 事件..." + placeholder="搜索海缆、登陆点、卫星、算力中心、BGP 事件..." >