release: bump version to 0.66.1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
rayd1o
2026-05-26 04:38:18 +08:00
parent 5bf5c73ca0
commit 887fec972e
17 changed files with 667 additions and 56 deletions

View File

@@ -1 +1 @@
0.66.0
0.66.1

View File

@@ -16,13 +16,24 @@ from app.core.logging import get_logger
from app.core.satellite_tle import build_tle_lines_from_elements
from app.services.business_logs import emit_business_log, exception_context
from app.services.collectors.base import BaseCollector
from app.services.collectors.downloads import ResumableFileDownloader
from app.services.collectors.downloads import DownloadHTTPStatusError, ResumableFileDownloader
logger = get_logger(__name__, service="collector")
ACTIVE_GROUP = "active"
FALLBACK_GROUPS = (
"starlink",
"gps-ops",
"galileo",
"glonass",
"beidou",
"leo",
"geo",
"iridium-next",
)
FETCH_RETRY_ATTEMPTS = 3
FETCH_RETRY_BASE_DELAY_SECONDS = 0.8
CELESTRAK_NOT_UPDATED_MARKER = "GP data has not updated since your last successful"
class CelesTrakTLECollector(BaseCollector):
@@ -41,9 +52,12 @@ class CelesTrakTLECollector(BaseCollector):
return self._resolved_url or ""
def _active_url(self) -> str:
return self._group_url(ACTIVE_GROUP)
def _group_url(self, group: str) -> str:
if not self.base_url:
raise RuntimeError("CelesTrak base URL is not configured")
return f"{self.base_url}?{urlencode({'GROUP': ACTIVE_GROUP, 'FORMAT': 'json'})}"
return f"{self.base_url}?{urlencode({'GROUP': group, 'FORMAT': 'json'})}"
async def fetch(self) -> List[Dict[str, Any]]:
url = self._active_url()
@@ -76,14 +90,7 @@ class CelesTrakTLECollector(BaseCollector):
progress_callback=self._report_download_progress,
validate_existing=self._validate_json_file,
)
try:
data = self._load_active_payload(body_path)
except RuntimeError as exc:
await self._log_parse_failure(exc)
raise
for item in data:
item["_celestrak_query_group"] = ACTIVE_GROUP
item["_celestrak_source_url"] = url
data = await self._load_downloaded_payload(body_path, url)
await emit_business_log(
logger,
event="collector.celestrak.download.success",
@@ -101,6 +108,54 @@ class CelesTrakTLECollector(BaseCollector):
},
)
return data
except DownloadHTTPStatusError as exc:
if self._is_not_updated_response(exc):
cached_path = self._downloader.get_cached_file(
url,
".json",
validate_existing=self._validate_json_file,
)
if cached_path is not None:
data = await self._load_downloaded_payload(cached_path, url)
await emit_business_log(
logger,
event="collector.celestrak.download.cached_not_updated",
message="CelesTrak active satellite data has not changed; using cached download",
category="collector",
level="warning",
service="collector",
module=__name__,
context={
"collector_name": self.name,
"datasource_id": getattr(self, "_datasource_id", None),
"group": ACTIVE_GROUP,
"attempt": attempt,
"record_count": len(data),
"duration_ms": self._duration_ms(started_at),
},
)
return data
await emit_business_log(
logger,
event="collector.celestrak.download.not_updated_no_cache",
message="CelesTrak active satellite data has not changed; trying fallback groups",
category="collector",
level="warning",
service="collector",
module=__name__,
context=exception_context(
exc,
{
"collector_name": self.name,
"datasource_id": getattr(self, "_datasource_id", None),
"group": ACTIVE_GROUP,
"attempt": attempt,
"duration_ms": self._duration_ms(started_at),
},
),
)
return await self._fetch_fallback_groups(client, active_error=exc)
raise
except Exception as exc:
last_error = exc
is_final_attempt = attempt >= FETCH_RETRY_ATTEMPTS
@@ -136,6 +191,142 @@ class CelesTrakTLECollector(BaseCollector):
raise RuntimeError(f"CelesTrak active satellite download failed after retries: {last_error}")
async def _fetch_fallback_groups(
self,
client: httpx.AsyncClient,
*,
active_error: DownloadHTTPStatusError,
) -> List[Dict[str, Any]]:
started_at = perf_counter()
records_by_norad: dict[str, Dict[str, Any]] = {}
group_counts: dict[str, int] = {}
await emit_business_log(
logger,
event="collector.celestrak.fallback_groups.start",
message="CelesTrak fallback group download started",
category="collector",
level="warning",
service="collector",
module=__name__,
context={
"collector_name": self.name,
"datasource_id": getattr(self, "_datasource_id", None),
"groups": list(FALLBACK_GROUPS),
"reason": "active_not_updated_without_cache",
},
)
try:
for group in FALLBACK_GROUPS:
group_url = self._group_url(group)
try:
body_path = await self._downloader.download_file(
client,
group_url,
extension=".json",
accept="application/json",
validate_existing=self._validate_json_file,
)
except DownloadHTTPStatusError as exc:
if not self._is_not_updated_response(exc):
raise RuntimeError(f"CelesTrak fallback group '{group}' download failed: {exc}") from exc
cached_path = self._downloader.get_cached_file(
group_url,
".json",
validate_existing=self._validate_json_file,
)
if cached_path is None:
raise RuntimeError(
f"CelesTrak fallback group '{group}' has not updated and no local cached copy is available"
) from exc
body_path = cached_path
group_records = await self._load_downloaded_payload(
body_path,
group_url,
query_group=group,
constellation_group=group,
)
group_counts[group] = len(group_records)
for item in group_records:
norad_cat_id = item.get("NORAD_CAT_ID")
if norad_cat_id is None:
continue
records_by_norad.setdefault(str(norad_cat_id), item)
except Exception as exc:
await emit_business_log(
logger,
event="collector.celestrak.fallback_groups.failed",
message="CelesTrak fallback group download failed",
category="collector",
level="error",
service="collector",
module=__name__,
context=exception_context(
exc,
{
"collector_name": self.name,
"datasource_id": getattr(self, "_datasource_id", None),
"groups": list(FALLBACK_GROUPS),
"completed_groups": list(group_counts),
"duration_ms": self._duration_ms(started_at),
},
),
)
raise RuntimeError(
"CelesTrak active data has not updated since this network's last successful download, "
"no active cache is available, and fallback group mode failed. Wait until CelesTrak "
"publishes the next GP update, restore the Planet download cache, or use Space-Track."
) from active_error
records = list(records_by_norad.values())
if not records:
raise RuntimeError("CelesTrak fallback group mode produced no satellite records")
await emit_business_log(
logger,
event="collector.celestrak.fallback_groups.success",
message="CelesTrak fallback group download completed",
category="collector",
level="warning",
service="collector",
module=__name__,
context={
"collector_name": self.name,
"datasource_id": getattr(self, "_datasource_id", None),
"groups": list(FALLBACK_GROUPS),
"group_counts": group_counts,
"record_count": len(records),
"duration_ms": self._duration_ms(started_at),
},
)
return records
async def _load_downloaded_payload(
self,
body_path: Path,
url: str,
*,
query_group: str = ACTIVE_GROUP,
constellation_group: str | None = None,
) -> List[Dict[str, Any]]:
try:
data = self._load_active_payload(body_path)
except RuntimeError as exc:
await self._log_parse_failure(exc)
raise
for item in data:
item["_celestrak_query_group"] = query_group
item["_celestrak_source_url"] = url
if constellation_group:
item["_celestrak_group"] = constellation_group
return data
@staticmethod
def _is_not_updated_response(exc: DownloadHTTPStatusError) -> bool:
return exc.status_code == 403 and CELESTRAK_NOT_UPDATED_MARKER in exc.body
@staticmethod
def _duration_ms(started_at: float) -> int:
return int((perf_counter() - started_at) * 1000)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import hashlib
import json
import tempfile
import os
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -17,6 +17,31 @@ ProgressCallback = Callable[[int, int | None], Awaitable[None]]
ValidateCallback = Callable[[Path], bool]
class DownloadHTTPStatusError(RuntimeError):
"""HTTP status error that keeps the upstream response body for caller-specific handling."""
def __init__(self, *, url: str, status_code: int, body: str) -> None:
self.url = url
self.status_code = status_code
self.body = body
preview = body.strip().replace("\r", " ").replace("\n", " ")[:240]
suffix = f": {preview}" if preview else ""
super().__init__(f"HTTP {status_code} while downloading {url}{suffix}")
def default_download_cache_root() -> Path:
configured = os.getenv("PLANET_DOWNLOAD_CACHE_DIR")
if configured:
return Path(configured).expanduser()
planet_cache = os.getenv("PLANET_CACHE_DIR")
if planet_cache:
return Path(planet_cache).expanduser() / "downloads"
xdg_cache = os.getenv("XDG_CACHE_HOME")
if xdg_cache:
return Path(xdg_cache).expanduser() / "planet" / "downloads"
return Path.home() / ".cache" / "planet" / "downloads"
class ResumableFileDownloader:
"""Download files with cache validators and byte-range resume support."""
@@ -26,8 +51,9 @@ class ResumableFileDownloader:
cache_namespace: str,
user_agent: str = "Planet-Intelligence-System/1.0 (Python/collector)",
default_accept: str = "*/*",
cache_root: Path | None = None,
) -> None:
self._cache_dir = Path(tempfile.gettempdir()) / "planet-download-cache" / cache_namespace
self._cache_dir = (cache_root or default_download_cache_root()) / cache_namespace
self._user_agent = user_agent
self._default_accept = default_accept
@@ -43,6 +69,25 @@ class ResumableFileDownloader:
meta_path = self._cache_dir / f"{key}.meta.json"
return final_path, part_path, meta_path
def cached_file_path(self, url: str, extension: str) -> Path:
final_path, _, _ = self._cache_paths(url, extension)
return final_path
def get_cached_file(
self,
url: str,
extension: str,
*,
validate_existing: ValidateCallback | None = None,
) -> Path | None:
final_path = self.cached_file_path(url, extension)
if not final_path.exists():
return None
if validate_existing and not validate_existing(final_path):
final_path.unlink(missing_ok=True)
return None
return final_path
@staticmethod
def _load_meta(meta_path: Path) -> dict[str, Any]:
if not meta_path.exists():
@@ -140,7 +185,9 @@ class ResumableFileDownloader:
if progress_callback and expected_size and expected_size > 0:
await progress_callback(expected_size, expected_size)
return final_path
response.raise_for_status()
if response.status_code >= 400:
body = (await response.aread()).decode("utf-8", errors="replace")
raise DownloadHTTPStatusError(url=url, status_code=response.status_code, body=body)
if response.status_code == 206 and resume_from > 0:
mode = "ab"

View File

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.services.collectors.celestrak import CelesTrakTLECollector
from app.services.collectors.downloads import DownloadHTTPStatusError, ResumableFileDownloader
from app.services.credential_guides import DEFAULT_CREDENTIAL_GUIDES
from app.services.collectors.top500 import TOP500Collector
from app.services.collectors.registry import collector_registry
@@ -222,6 +223,101 @@ class TestCelesTrakTLECollector:
assert attempts == 3
@pytest.mark.asyncio
async def test_fetch_uses_cache_when_celestrak_reports_not_updated(self, monkeypatch, tmp_path):
collector = CelesTrakTLECollector()
collector._resolved_url = "https://celestrak.example/NORAD/elements/gp.php"
collector._downloader = ResumableFileDownloader(cache_namespace="celestrak-test", cache_root=tmp_path)
url = collector._active_url()
cached_path = collector._downloader.cached_file_path(url, ".json")
cached_path.parent.mkdir(parents=True, exist_ok=True)
cached_path.write_text(
json.dumps([{"NORAD_CAT_ID": 25544, "OBJECT_NAME": "ISS (ZARYA)"}]),
encoding="utf-8",
)
async def fake_download_file(*args, **kwargs):
raise DownloadHTTPStatusError(
url=url,
status_code=403,
body="GP data has not updated since your last successful download of GROUP=active.",
)
async def fake_emit_business_log(*args, **kwargs):
return None
monkeypatch.setattr(collector._downloader, "download_file", fake_download_file)
monkeypatch.setattr("app.services.collectors.celestrak.emit_business_log", fake_emit_business_log)
records = await collector.fetch()
assert records[0]["NORAD_CAT_ID"] == 25544
assert records[0]["_celestrak_query_group"] == "active"
@pytest.mark.asyncio
async def test_fetch_not_updated_without_cache_does_not_retry(self, monkeypatch, tmp_path):
collector = CelesTrakTLECollector()
collector._resolved_url = "https://celestrak.example/NORAD/elements/gp.php"
collector._downloader = ResumableFileDownloader(cache_namespace="celestrak-test", cache_root=tmp_path)
attempts = 0
async def fake_download_file(*args, **kwargs):
nonlocal attempts
attempts += 1
raise DownloadHTTPStatusError(
url=collector._active_url(),
status_code=403,
body="GP data has not updated since your last successful download of GROUP=active.",
)
async def fake_emit_business_log(*args, **kwargs):
return None
monkeypatch.setattr(collector._downloader, "download_file", fake_download_file)
monkeypatch.setattr("app.services.collectors.celestrak.FALLBACK_GROUPS", ("starlink",))
monkeypatch.setattr("app.services.collectors.celestrak.emit_business_log", fake_emit_business_log)
monkeypatch.setattr("app.services.collectors.celestrak.asyncio.sleep", AsyncMock())
with pytest.raises(RuntimeError, match="fallback group mode failed"):
await collector.fetch()
assert attempts == 2
@pytest.mark.asyncio
async def test_fetch_falls_back_to_all_groups_when_active_not_updated_without_cache(self, monkeypatch, tmp_path):
collector = CelesTrakTLECollector()
collector._resolved_url = "https://celestrak.example/NORAD/elements/gp.php"
collector._downloader = ResumableFileDownloader(cache_namespace="celestrak-test", cache_root=tmp_path)
payload_by_group = {
"starlink": [{"NORAD_CAT_ID": 100, "OBJECT_NAME": "STARLINK-100"}],
"gps-ops": [{"NORAD_CAT_ID": 200, "OBJECT_NAME": "GPS BIIR-2"}],
}
async def fake_download_file(_client, url, **_kwargs):
if "GROUP=active" in url:
raise DownloadHTTPStatusError(
url=url,
status_code=403,
body="GP data has not updated since your last successful download of GROUP=active.",
)
group = "starlink" if "GROUP=starlink" in url else "gps-ops"
path = tmp_path / f"{group}.json"
path.write_text(json.dumps(payload_by_group[group]), encoding="utf-8")
return path
async def fake_emit_business_log(*args, **kwargs):
return None
monkeypatch.setattr(collector._downloader, "download_file", fake_download_file)
monkeypatch.setattr("app.services.collectors.celestrak.FALLBACK_GROUPS", tuple(payload_by_group))
monkeypatch.setattr("app.services.collectors.celestrak.emit_business_log", fake_emit_business_log)
records = await collector.fetch()
assert [item["NORAD_CAT_ID"] for item in records] == [100, 200]
assert records[0]["_celestrak_query_group"] == "starlink"
assert records[1]["_celestrak_group"] == "gps-ops"
def test_aisstream_collector_is_registered():
collector = collector_registry.get("aisstream_vessels")

View File

@@ -8,6 +8,22 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.66.1] — 2026-05-26
Released: 2026-05-26
### Highlights
- 修复 CelesTrak active 更新窗口内清库后无法恢复的问题,新增持久原始下载缓存和完整 fallback group mode。
- 避免数据源任务 WebSocket 与轮询同时完成时重复弹出采集失败 toast。
- `planet.sh destroy` 保留上游原始下载缓存,数据库清空后仍可用缓存重灌。
### Added / Fixed / Improved
- CelesTrak 403 `GP data has not updated` 会先复用 active 缓存;无 active 缓存时完整拉取 `starlink/gps-ops/galileo/glonass/beidou/leo/geo/iridium-next`,任一 group 缺失则整体失败,不保存 partial。
- 下载器缓存从 `/tmp` 迁到 `$PLANET_CACHE_DIR/downloads`,并保留 HTTP 错误响应正文以支持上游限频语义判断。
- 补充 CelesTrak cache/fallback 回归测试和中英文运维/卫星策略文档。
---
## [0.66.0] — 2026-05-26
Released: 2026-05-26

View File

@@ -17,7 +17,13 @@ Related context:
## Current Local Categories
The CelesTrak collector now downloads the complete active satellite catalog from `GROUP=active&FORMAT=json` instead of fetching several smaller groups and merging them. This prevents one failed CelesTrak group request from being saved as a successful but incomplete batch. The collector only proceeds when the downloaded JSON is a parseable array and records include `NORAD_CAT_ID`; network, resume, or parsing failures are retried, and final failure preserves the previous current dataset.
The CelesTrak collector now prefers the complete active satellite catalog from `GROUP=active&FORMAT=json`. This prevents one failed CelesTrak group request from being saved as a successful but incomplete batch. The collector only proceeds when the downloaded JSON is a parseable array and records include `NORAD_CAT_ID`; network, resume, or parsing failures are retried, and final failure preserves the previous current dataset.
CelesTrak applies a repeat-download window to large groups such as `active`. To make database resets recoverable, Planet stores raw downloads under `$PLANET_CACHE_DIR/downloads`. When `active` returns the CelesTrak "GP data has not updated" HTTP 403:
- If an `active` cache exists, the collector reuses it to repopulate the database.
- If no `active` cache exists, the collector switches to fallback group mode and downloads `starlink`, `gps-ops`, `galileo`, `glonass`, `beidou`, `leo`, `geo`, and `iridium-next`.
- Fallback group mode requires every group to succeed or have a reusable cache; if any group is missing, the whole collection fails and no partial dataset is saved.
The collector still provides `metadata.constellation_group` to the frontend, but the value now comes from executable inference:
@@ -25,7 +31,7 @@ The collector still provides `metadata.constellation_group` to the frontend, but
- `OBJECT_NAME` starting with `IRIDIUM` is marked as `iridium-next`
- Other active satellites are not forced into the old CelesTrak small-group labels, because a broad source group is not an exact constellation
The product policy therefore still discusses GNSS/RNSS, GEO, generic LEO, and Iridium NEXT semantics, but code should no longer assume that saved CelesTrak rows carry the old `gps-ops`, `galileo`, `glonass`, `beidou`, `leo`, or `geo` group labels.
The product policy therefore still discusses GNSS/RNSS, GEO, generic LEO, and Iridium NEXT semantics. However, only fallback group mode stores the old `gps-ops`, `galileo`, `glonass`, `beidou`, `leo`, or `geo` labels; the active primary path does not force every satellite into those labels.
## Research Conclusions

View File

@@ -73,9 +73,9 @@ Cleanup order and boundaries:
- If `planet_postgres` is running, the script first clears the `public` schema in `planet_db`. This prevents old `collected_data.is_current = true` rows from making Earth OOBE report `ready=true` if Docker volume removal later fails.
- Docker cleanup targets resources whose Compose project is `planet`, plus the explicit volumes `planet_postgres_data`, `planet_redis_data`, `postgres_data`, and `redis_data`; do not delete unlabeled volumes by a broad `planet_*` pattern, because another local project could own them.
- Local build state removes `.venv`, frontend `node_modules` / `dist`, Planet state/cache, and scattered Python / Vite cache directories.
- Local build state removes `.venv`, frontend `node_modules` / `dist`, Planet state, and scattered Python / Vite cache directories. `$PLANET_CACHE_DIR/downloads` is preserved so upstream raw downloads such as CelesTrak can survive database resets and local rebuild cleanup.
After the reset, run `./planet.sh init` again to recreate tables and default seed data. Old collected records are not restored, and Earth OOBE is evaluated from the backend's real collection state on the next visit.
After the reset, run `./planet.sh init` again to recreate tables and default seed data. Old collected records are not restored, and Earth OOBE is evaluated from the backend's real collection state on the next visit. When CelesTrak later returns its "GP data has not updated" HTTP 403, the backend first reuses the preserved download cache to repopulate the database; if no cache exists, wait for the next CelesTrak update window or use Space-Track as a fallback.
## Health Check

View File

@@ -17,7 +17,13 @@
## 本地实际类别
当前 CelesTrak 采集器从 `GROUP=active&FORMAT=json` 拉取完整活跃卫星目录,而不是逐个小分组拉取后合并。这样可以避免某个 CelesTrak 分组请求失败时仍把不完整结果保存为成功批次。采集器只在完整 JSON 数组可解析、且记录含 `NORAD_CAT_ID` 时进入转换和保存;网络、续传或解析失败会重试,最终失败时保留上一批 current 数据。
当前 CelesTrak 采集器优先`GROUP=active&FORMAT=json` 拉取完整活跃卫星目录。这样可以避免某个 CelesTrak 分组请求失败时仍把不完整结果保存为成功批次。采集器只在完整 JSON 数组可解析、且记录含 `NORAD_CAT_ID` 时进入转换和保存;网络、续传或解析失败会重试,最终失败时保留上一批 current 数据。
CelesTrak 对 `active` 这类大 group 有每次 GP 数据更新窗口内的重复下载限制。为避免清空数据库后无法立即恢复Planet 会把原始下载文件保存在 `$PLANET_CACHE_DIR/downloads`。当 `active` 返回“本轮数据未更新”的 403 时:
- 如果 `active` 缓存存在,直接用缓存重新写入数据库。
- 如果 `active` 缓存不存在,才切换到 fallback group mode`starlink``gps-ops``galileo``glonass``beidou``leo``geo``iridium-next` 全部下载并合并。
- fallback group mode 要求所有 group 都成功或有缓存可复用;任意 group 缺失都会整体失败,不保存 partial。
采集结果仍会给前端提供 `metadata.constellation_group`,但该字段现在来自可执行推断:
@@ -25,7 +31,7 @@
- `OBJECT_NAME``IRIDIUM` 开头时标记为 `iridium-next`
- 其它活跃卫星不强行归入旧 CelesTrak 小分组,避免把泛化类别当成精确星座
因此,非 Starlink 类别在产品策略中仍包括 GNSS/RNSS、GEO、generic LEO 和 Iridium NEXT 等语义,但不能再假设采集器保存旧的 `gps-ops``galileo``glonass``beidou``leo``geo` 分组标签。
因此,非 Starlink 类别在产品策略中仍包括 GNSS/RNSS、GEO、generic LEO 和 Iridium NEXT 等语义;但只有 fallback group mode 会保存旧的 `gps-ops``galileo``glonass``beidou``leo``geo` 分组标签active 主路径不会强行给所有卫星补这类标签
## 资料结论

View File

@@ -73,9 +73,9 @@
- 如果 `planet_postgres` 正在运行,脚本会先清空 `planet_db``public` schema。这样即使后续 Docker volume 删除失败,旧的 `collected_data.is_current = true` 也不会让 Earth OOBE 继续显示 `ready=true`
- Docker 清理只针对 Compose project 为 `planet` 的资源,以及显式列出的 `planet_postgres_data``planet_redis_data``postgres_data``redis_data`;不要按 `planet_*` 模式删除没有 label 的 volume避免误删同机其他项目。
- 本地编译状态会删除 `.venv`、前端 `node_modules` / `dist`、Planet state/cache,以及散落的 Python / Vite 缓存目录。
- 本地编译状态会删除 `.venv`、前端 `node_modules` / `dist`、Planet state以及散落的 Python / Vite 缓存目录`$PLANET_CACHE_DIR/downloads` 会保留,用于保存 CelesTrak 这类受上游下载窗口限制的原始文件缓存
重置后重新执行 `./planet.sh init` 会重建表和默认数据,但不会恢复旧采集结果;首次进入 Earth 时 OOBE 会重新按后端真实采集状态判断。
重置后重新执行 `./planet.sh init` 会重建表和默认数据,但不会恢复旧采集结果;首次进入 Earth 时 OOBE 会重新按后端真实采集状态判断。触发 CelesTrak 采集时,如果上游返回“本轮 GP 数据未更新”的 403后端会优先用保留的下载缓存重新写入数据库如果下载缓存也不存在只能等待 CelesTrak 下一次更新窗口或使用 Space-Track 作为 fallback。
## 健康检查

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.66.0`
- `dev` 当前开发分支历史推导到:`0.66.1`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.66.1` | bugfix | `dev` | `pending` | CelesTrak active 限频时复用持久下载缓存并完整 fallback groupdestroy 保留原始下载缓存,同时修复采集失败 toast 重复弹出 |
| `0.66.0` | feature | `dev` | `pending` | Admin 正式化为唯一控制台,新增数据作业/outbox 与 Earth interactables 管线,补齐 AI/采集日志,修复 CelesTrak 完整 active 目录采集和内置源启停判断 |
| `0.65.2` | bugfix | `dev` | `pending` | AI Provider 镜像重建判定改为内容 fingerprint 与镜像 label启动链路改用 frozen uv避免用户级镜像源污染 `uv.lock`,并加入 Windows 一键启动脚本 |
| `0.65.1` | bugfix | `dev` | `pending` | 统一 `planet.sh` 与 Compose 的 AI Provider 镜像名,并让本地和 Docker build 通过用户级 `uv.toml` 共享 uv 源配置,避免镜像源污染 `uv.lock` |

View File

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

View File

@@ -2495,6 +2495,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
const taskId = text(payload.task_id, sourceId || source)
const pendingKey = taskId || sourceId || source
if (!pendingKey || completedDatasourceTasksRef.current.has(pendingKey)) return
completedDatasourceTasksRef.current.add(pendingKey)
const pendingEntry = pendingDatasourceTasksRef.current[pendingKey]
? [pendingKey, pendingDatasourceTasksRef.current[pendingKey]] as const
: Object.entries(pendingDatasourceTasksRef.current).find(([, item]) => isSameDatasourceRow({ id: item.sourceId, source: item.source }, sourceId, source))
@@ -2512,7 +2513,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
updateDatasourceRow({ ...payload, id: sourceId || pending?.sourceId, source: source || pending?.source }, { removeIfFilteredOut: true })
}
completedDatasourceTasksRef.current.add(pendingKey)
if (pending) {
const finalStatus = datasourceStatus(finalRow)
const titleName = pending.name || text(finalRow.name || finalRow.source, '数据源')

View File

@@ -1,6 +1,6 @@
import { Check, Copy } from 'lucide-react'
import { memo, useEffect, useId, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from 'react'
import Scrollbar from '../Scrollbar/Scrollbar'
@@ -161,6 +161,11 @@ async function copyToClipboard(text: string): Promise<void> {
document.body.removeChild(textarea)
}
function isMermaidTextTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false
return Boolean(target.closest('text, tspan, foreignObject, .nodeLabel, .edgeLabel, .label'))
}
function MarkdownCodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false)
const label = language?.trim() || 'text'
@@ -200,8 +205,18 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
const [svg, setSvg] = useState('')
const [error, setError] = useState('')
const [themeMode, setThemeMode] = useState<'light' | 'dark'>('light')
const [isExpanded, setIsExpanded] = useState(false)
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const blockId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const containerRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startX: number
startY: number
initialX: number
initialY: number
} | null>(null)
useEffect(() => {
const themeElement = containerRef.current?.closest('[data-theme]')
@@ -269,31 +284,138 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS)
}
const openExpanded = () => {
if (!svg) return
setZoom(1)
setPan({ x: 0, y: 0 })
setIsExpanded(true)
}
const closeExpanded = () => {
dragRef.current = null
setIsExpanded(false)
}
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
event.preventDefault()
const direction = event.deltaY > 0 ? -1 : 1
setZoom((value) => Math.min(4, Math.max(0.35, value + direction * 0.12)))
}
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!isExpanded) return
if (isMermaidTextTarget(event.target)) return
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
initialX: pan.x,
initialY: pan.y,
}
}
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
setPan({
x: drag.initialX + event.clientX - drag.startX,
y: drag.initialY + event.clientY - drag.startY,
})
}
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null
}
}
return (
<div className="markdown-renderer__mermaid-block" ref={containerRef}>
<div className="markdown-renderer__code-toolbar markdown-renderer__mermaid-toolbar">
<span className="markdown-renderer__code-language">mermaid</span>
<button
type="button"
className="markdown-renderer__code-copy"
onClick={handleCopy}
aria-label={copied ? '已复制图表源码' : '复制图表源码'}
title={copied ? '已复制' : '复制图表源码'}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
{svg ? (
<div className="markdown-renderer__mermaid-canvas" dangerouslySetInnerHTML={{ __html: svg }} />
) : error ? (
<div className="markdown-renderer__mermaid-error">
<strong>Mermaid render failed</strong>
<span>{error}</span>
<>
<div className="markdown-renderer__mermaid-block" ref={containerRef}>
<div className="markdown-renderer__code-toolbar markdown-renderer__mermaid-toolbar">
<span className="markdown-renderer__code-language">mermaid</span>
<button
type="button"
className="markdown-renderer__code-copy"
onClick={handleCopy}
aria-label={copied ? '已复制图表源码' : '复制图表源码'}
title={copied ? '已复制' : '复制图表源码'}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
) : (
<div className="markdown-renderer__mermaid-loading">Rendering diagram...</div>
)}
</div>
{svg ? (
<div
role="button"
tabIndex={0}
className="markdown-renderer__mermaid-canvas"
onClick={(event) => {
if (isMermaidTextTarget(event.target)) return
if (window.getSelection()?.toString()) return
openExpanded()
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openExpanded()
}
}}
aria-label="放大查看 Mermaid 图表"
title="点击放大查看"
>
<span className="markdown-renderer__mermaid-canvas-inner" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
) : error ? (
<div className="markdown-renderer__mermaid-error">
<strong>Mermaid render failed</strong>
<span>{error}</span>
</div>
) : (
<div className="markdown-renderer__mermaid-loading">Rendering diagram...</div>
)}
</div>
{isExpanded && svg ? (
<div
className="markdown-renderer__mermaid-viewer"
role="dialog"
aria-modal="true"
aria-label="Mermaid 图表查看器"
onClick={closeExpanded}
>
<button
type="button"
className="markdown-renderer__mermaid-viewer-close"
onClick={closeExpanded}
aria-label="关闭 Mermaid 图表查看器"
title="关闭"
>
×
</button>
<div
className="markdown-renderer__mermaid-viewer-stage"
onClick={(event) => event.stopPropagation()}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<div
className="markdown-renderer__mermaid-viewer-content"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
<div className="markdown-renderer__mermaid-viewer-hint">
· ·
</div>
</div>
) : null}
</>
)
}

View File

@@ -673,14 +673,135 @@
.docs-markdown .markdown-renderer__mermaid-canvas {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 180px;
padding: 22px;
border: 0;
background: transparent;
color: inherit;
overflow-x: auto;
cursor: zoom-in;
user-select: none;
}
.docs-markdown .markdown-renderer__mermaid-canvas svg {
max-width: 100%;
.docs-markdown .markdown-renderer__mermaid-canvas-inner {
display: flex;
justify-content: center;
width: 100%;
}
.docs-markdown .markdown-renderer__mermaid-canvas-inner svg {
width: 100%;
max-width: min(100%, 1120px);
height: auto;
min-height: 160px;
user-select: none;
}
.docs-markdown .markdown-renderer__mermaid-canvas-inner svg text {
user-select: text;
-webkit-user-select: text;
}
.docs-markdown .markdown-renderer__mermaid-canvas-inner svg tspan,
.docs-markdown .markdown-renderer__mermaid-canvas-inner svg foreignObject,
.docs-markdown .markdown-renderer__mermaid-canvas-inner svg foreignObject * {
user-select: text;
-webkit-user-select: text;
}
.markdown-renderer__mermaid-viewer {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 42px;
background: rgba(3, 7, 18, 0.78);
backdrop-filter: blur(10px);
}
.markdown-renderer__mermaid-viewer-stage {
width: min(1180px, calc(100vw - 84px));
height: min(760px, calc(100vh - 112px));
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 8px;
background: var(--d-mermaid-bg);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.4);
cursor: grab;
touch-action: none;
user-select: none;
}
.markdown-renderer__mermaid-viewer-stage:active {
cursor: grabbing;
}
.markdown-renderer__mermaid-viewer-content {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
transform-origin: center;
transition: transform 0.08s ease-out;
}
.markdown-renderer__mermaid-viewer-content svg {
max-width: 92%;
max-height: 92%;
width: auto;
height: auto;
user-select: none;
}
.markdown-renderer__mermaid-viewer-content svg text {
user-select: text;
-webkit-user-select: text;
}
.markdown-renderer__mermaid-viewer-content svg tspan,
.markdown-renderer__mermaid-viewer-content svg foreignObject,
.markdown-renderer__mermaid-viewer-content svg foreignObject * {
user-select: text;
-webkit-user-select: text;
}
.markdown-renderer__mermaid-viewer-close {
position: fixed;
top: 18px;
right: 20px;
z-index: 1;
width: 36px;
height: 36px;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 8px;
background: rgba(15, 23, 42, 0.78);
color: #e5eefb;
font-size: 26px;
line-height: 1;
cursor: pointer;
}
.markdown-renderer__mermaid-viewer-close:hover {
background: rgba(30, 41, 59, 0.92);
color: #ffffff;
}
.markdown-renderer__mermaid-viewer-hint {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
padding: 8px 12px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: #cbd5e1;
font-size: 12px;
pointer-events: none;
}
.docs-markdown .markdown-renderer__mermaid-loading,

View File

@@ -2172,6 +2172,8 @@ start_backend_with_retry() {
: > "$BACKEND_LOG_FILE"
BACKEND_PID="$(start_detached_command "$BACKEND_LOG_FILE" \
env PYTHONPATH="$SCRIPT_DIR/backend" \
PLANET_CACHE_DIR="$PLANET_CACHE_DIR" \
PLANET_DOWNLOAD_CACHE_DIR="${PLANET_DOWNLOAD_CACHE_DIR:-$PLANET_CACHE_DIR/downloads}" \
uv run --frozen --project "$SCRIPT_DIR" python -m uvicorn app.main:app \
--host "$backend_bind_host" --port "$backend_port" --reload)"
write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID"
@@ -3780,8 +3782,11 @@ remove_planet_build_state() {
"$SCRIPT_DIR/frontend/node_modules" \
"$SCRIPT_DIR/frontend/dist" \
"$SCRIPT_DIR/frontend/dist-ssr" \
"$PLANET_STATE_DIR" \
"$PLANET_CACHE_DIR"
"$PLANET_STATE_DIR"
if [ -d "$PLANET_CACHE_DIR" ]; then
find "$PLANET_CACHE_DIR" -mindepth 1 -maxdepth 1 ! -name downloads -exec rm -rf {} +
fi
find "$SCRIPT_DIR" \
\( -path "$SCRIPT_DIR/.git" -o -path "$SCRIPT_DIR/frontend/node_modules" -o -path "$SCRIPT_DIR/node_modules" \) -prune \
@@ -3815,7 +3820,7 @@ destroy() {
log_success "本地编译和运行状态已清理"
log_success "destroy 完成"
log_note "已保留源码.env 配置文件;重新开始可执行 ./planet.sh init"
log_note "已保留源码.env 配置文件和上游原始下载缓存;重新开始可执行 ./planet.sh init"
}
stop_local_services_for_restart() {

View File

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

2
uv.lock generated
View File

@@ -757,7 +757,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.66.0"
version = "0.66.1"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },