release: bump version to 0.45.0

This commit is contained in:
rayd1o
2026-04-29 23:43:54 +08:00
parent 9dafbf4f6e
commit ba54545ac7
32 changed files with 602 additions and 29 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
**
!pyproject.toml
!uv.lock
!aiprovider/
!aiprovider/**
aiprovider/.env
aiprovider/.env.*
!aiprovider/.env.example
**/__pycache__/
**/*.pyc
**/*.pyo

View File

@@ -1 +1 @@
0.44.2
0.45.0

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
ARG PYTHON_IMAGE=python:3.14-slim
ARG UV_IMAGE=ghcr.io/astral-sh/uv:latest
@@ -18,9 +20,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock /app/
RUN uv sync --frozen --no-dev
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
COPY . /app
COPY aiprovider /app/aiprovider
EXPOSE 8010

View File

@@ -398,6 +398,11 @@ async def list_datasources(
"task_id": running_task.id if running_task else None,
"progress": running_task.progress if running_task else None,
"phase": running_task.phase if running_task else None,
"phase_progress": running_task.phase_progress if running_task else None,
"phase_message": running_task.phase_message if running_task else None,
"phase_current": running_task.phase_current if running_task else None,
"phase_total": running_task.phase_total if running_task else None,
"phase_unit": running_task.phase_unit if running_task else None,
"records_processed": running_task.records_processed if running_task else None,
"total_records": running_task.total_records if running_task else None,
}
@@ -626,6 +631,11 @@ async def trigger_datasource(
"message": "当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?",
"task_id": running_task.id,
"phase": running_task.phase,
"phase_progress": running_task.phase_progress,
"phase_message": running_task.phase_message,
"phase_current": running_task.phase_current,
"phase_total": running_task.phase_total,
"phase_unit": running_task.phase_unit,
"progress": running_task.progress,
"records_processed": running_task.records_processed,
"total_records": running_task.total_records,
@@ -709,13 +719,29 @@ async def get_task_status(
task = await get_running_task(db, datasource.id)
if not task:
return {"is_running": False, "task_id": None, "progress": None, "phase": None, "status": "idle"}
return {
"is_running": False,
"task_id": None,
"progress": None,
"phase": None,
"phase_progress": None,
"phase_message": None,
"phase_current": None,
"phase_total": None,
"phase_unit": None,
"status": "idle",
}
return {
"is_running": task.status == "running",
"task_id": task.id,
"progress": task.progress,
"phase": task.phase,
"phase_progress": task.phase_progress,
"phase_message": task.phase_message,
"phase_current": task.phase_current,
"phase_total": task.phase_total,
"phase_unit": task.phase_unit,
"records_processed": task.records_processed,
"total_records": task.total_records,
"status": task.status,

View File

@@ -27,7 +27,9 @@ async def list_tasks(
offset = (page - 1) * page_size
query = """
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress
FROM collection_tasks ct
JOIN data_sources ds ON ct.datasource_id = ds.id
WHERE 1=1
@@ -66,6 +68,14 @@ async def list_tasks(
"completed_at": to_iso8601_utc(t[5]),
"records_processed": t[6],
"error_message": t[7],
"phase": t[8],
"phase_progress": t[9],
"phase_message": t[10],
"phase_current": t[11],
"phase_total": t[12],
"phase_unit": t[13],
"total_records": t[14],
"progress": t[15],
}
for t in tasks
],
@@ -81,7 +91,9 @@ async def get_task(
result = await db.execute(
text("""
SELECT ct.id, ct.datasource_id, ds.name as datasource_name, ct.status,
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message
ct.started_at, ct.completed_at, ct.records_processed, ct.error_message,
ct.phase, ct.phase_progress, ct.phase_message, ct.phase_current,
ct.phase_total, ct.phase_unit, ct.total_records, ct.progress
FROM collection_tasks ct
JOIN data_sources ds ON ct.datasource_id = ds.id
WHERE ct.id = :id
@@ -105,6 +117,14 @@ async def get_task(
"completed_at": to_iso8601_utc(task[5]),
"records_processed": task[6],
"error_message": task[7],
"phase": task[8],
"phase_progress": task[9],
"phase_message": task[10],
"phase_current": task[11],
"phase_total": task[12],
"phase_unit": task[13],
"total_records": task[14],
"progress": task[15],
}

View File

@@ -146,7 +146,12 @@ async def init_db():
text(
"""
ALTER TABLE collection_tasks
ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued'
ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued',
ADD COLUMN IF NOT EXISTS phase_progress DOUBLE PRECISION,
ADD COLUMN IF NOT EXISTS phase_message VARCHAR(255),
ADD COLUMN IF NOT EXISTS phase_current BIGINT,
ADD COLUMN IF NOT EXISTS phase_total BIGINT,
ADD COLUMN IF NOT EXISTS phase_unit VARCHAR(30)
"""
)
)

View File

@@ -1,6 +1,6 @@
"""Collection Task model"""
from sqlalchemy import Column, DateTime, Integer, String, Text, Float
from sqlalchemy import BigInteger, Column, DateTime, Integer, String, Text, Float
from sqlalchemy.sql import func
from app.db.session import Base
@@ -13,6 +13,11 @@ class CollectionTask(Base):
datasource_id = Column(Integer, nullable=False, index=True)
status = Column(String(20), nullable=False) # pending, running, success, failed, cancelled
phase = Column(String(30), default="queued")
phase_progress = Column(Float)
phase_message = Column(String(255))
phase_current = Column(BigInteger)
phase_total = Column(BigInteger)
phase_unit = Column(String(30))
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
records_processed = Column(Integer, default=0)

View File

@@ -54,6 +54,11 @@ class BaseCollector(ABC):
"task_id": self._current_task.id,
"status": self._current_task.status,
"phase": self._current_task.phase,
"phase_progress": self._current_task.phase_progress,
"phase_message": self._current_task.phase_message,
"phase_current": self._current_task.phase_current,
"phase_total": self._current_task.phase_total,
"phase_unit": self._current_task.phase_unit,
"progress": progress,
"records_processed": self._current_task.records_processed,
"total_records": self._current_task.total_records,
@@ -80,12 +85,52 @@ class BaseCollector(ABC):
await self._publish_task_update(force=force)
async def set_phase(self, phase: str):
async def set_phase(self, phase: str, *, message: str | None = None, reset_progress: bool = True):
if self._current_task and self._db_session:
self._current_task.phase = phase
self._current_task.phase_message = message
if reset_progress:
self._current_task.phase_progress = None
self._current_task.phase_current = None
self._current_task.phase_total = None
self._current_task.phase_unit = None
await self._db_session.commit()
await self._publish_task_update(force=True)
async def update_phase_progress(
self,
*,
current: int | None = None,
total: int | None = None,
unit: str | None = None,
message: str | None = None,
progress: float | None = None,
commit: bool = False,
force: bool = False,
):
"""Update progress for the current phase without changing task totals."""
if not self._current_task or not self._db_session:
return
if progress is None and current is not None and total and total > 0:
progress = (current / total) * 100
if progress is not None:
self._current_task.phase_progress = max(0.0, min(float(progress), 100.0))
if current is not None:
self._current_task.phase_current = max(0, int(current))
if total is not None:
self._current_task.phase_total = max(0, int(total))
if unit is not None:
self._current_task.phase_unit = unit
if message is not None:
self._current_task.phase_message = message
if commit:
await self._db_session.commit()
await self._publish_task_update(force=force)
@abstractmethod
async def fetch(self) -> List[Dict[str, Any]]:
"""Fetch raw data from source"""
@@ -251,7 +296,7 @@ class BaseCollector(ABC):
await self._publish_task_update(force=True)
try:
await self.set_phase("fetching")
await self.set_phase("fetching", message="正在拉取原始数据")
raw_data = await self.fetch()
task.total_records = len(raw_data)
await db.commit()
@@ -260,15 +305,20 @@ class BaseCollector(ABC):
if self.fail_on_empty and not raw_data:
raise RuntimeError(f"Collector {self.name} returned no data")
await self.set_phase("transforming")
await self.set_phase("transforming", message="正在转换采集数据")
data = self.transform(raw_data)
snapshot_id = await self._create_snapshot(db, task_id, data, start_time)
await self.set_phase("saving")
await self.set_phase("saving", message="正在保存采集数据")
records_count = await self._save_data(db, data, task_id=task_id, snapshot_id=snapshot_id)
task.status = "success"
task.phase = "completed"
task.phase_progress = 100.0
task.phase_message = "采集完成"
task.phase_current = records_count
task.phase_total = records_count
task.phase_unit = "records"
task.records_processed = records_count
task.progress = 100.0
task.completed_at = datetime.now(UTC)
@@ -285,6 +335,7 @@ class BaseCollector(ABC):
await db.rollback()
task.status = "cancelled"
task.phase = "cancelled"
task.phase_message = "采集已取消"
task.error_message = "Collection cancelled by operator and rolled back"
task.completed_at = datetime.now(UTC)
if snapshot_id is not None:
@@ -301,6 +352,7 @@ class BaseCollector(ABC):
await db.rollback()
task.status = "failed"
task.phase = "failed"
task.phase_message = str(e)
task.error_message = str(e)
task.completed_at = datetime.now(UTC)
if snapshot_id is not None:

View File

@@ -108,6 +108,11 @@ class IPtoASNPrefixGeoCollector(BaseCollector):
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
self._current_task.phase_progress = 0.0
self._current_task.phase_message = "正在下载 IPtoASN 数据"
self._current_task.phase_current = 0
self._current_task.phase_total = total_expected
self._current_task.phase_unit = "bytes"
await self._db_session.commit()
await self._publish_task_update(force=True)
@@ -135,7 +140,14 @@ class IPtoASNPrefixGeoCollector(BaseCollector):
return
last_emit["value"] = aggregated
last_emit["t"] = now
await self.update_progress(min(aggregated, total_expected), commit=True)
current = min(aggregated, total_expected)
await self.update_phase_progress(
current=current,
total=total_expected,
unit="bytes",
message="正在下载 IPtoASN 数据",
)
await self.update_progress(current, commit=True)
batches = await asyncio.gather(
*(
@@ -148,6 +160,12 @@ class IPtoASNPrefixGeoCollector(BaseCollector):
)
)
if total_expected > 0:
await self.update_phase_progress(
current=total_expected,
total=total_expected,
unit="bytes",
message="IPtoASN 数据下载完成",
)
await self.update_progress(total_expected, commit=True, force=True)
rows: list[dict[str, Any]] = []

View File

@@ -39,12 +39,23 @@ class NRODelegatedPrefixGeoCollector(BaseCollector):
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
self._current_task.phase_progress = 0.0
self._current_task.phase_message = "正在下载 NRO delegated 数据"
self._current_task.phase_current = 0
self._current_task.phase_total = total_expected
self._current_task.phase_unit = "bytes"
await self._db_session.commit()
await self._publish_task_update(force=True)
async def on_progress(downloaded: int, total: int | None) -> None:
if not total or total <= 0:
return
await self.update_phase_progress(
current=min(downloaded, total),
total=total,
unit="bytes",
message="正在下载 NRO delegated 数据",
)
await self.update_progress(min(downloaded, total), commit=True)
body_path = await self._downloader.download_file(

View File

@@ -40,12 +40,23 @@ class OpenGeoFeedPrefixGeoCollector(BaseCollector):
self._current_task.total_records = total_expected
self._current_task.records_processed = 0
self._current_task.progress = 0.0
self._current_task.phase_progress = 0.0
self._current_task.phase_message = "正在下载 OpenGeoFeed 数据"
self._current_task.phase_current = 0
self._current_task.phase_total = total_expected
self._current_task.phase_unit = "bytes"
await self._db_session.commit()
await self._publish_task_update(force=True)
async def on_progress(downloaded: int, total: int | None) -> None:
if not total or total <= 0:
return
await self.update_phase_progress(
current=min(downloaded, total),
total=total,
unit="bytes",
message="正在下载 OpenGeoFeed 数据",
)
await self.update_progress(min(downloaded, total), commit=True)
body_path = await self._downloader.download_file(

View File

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from app.services.collectors.top500 import TOP500Collector
from app.services.collectors.base import BaseCollector, HTTPCollector
from app.models.task import CollectionTask
class TestBaseCollector:
@@ -19,6 +20,31 @@ class TestBaseCollector:
assert collector.module == "L1"
assert collector.frequency_hours == 4
@pytest.mark.asyncio
async def test_update_phase_progress_tracks_phase_fields(self, mock_db_session):
"""Test phase-level progress updates independently from record totals"""
collector = TOP500Collector()
task = CollectionTask(datasource_id=1, status="running", phase="fetching")
collector._current_task = task
collector._db_session = mock_db_session
with patch.object(collector, "_publish_task_update", new=AsyncMock()) as publish:
await collector.update_phase_progress(
current=512,
total=1024,
unit="bytes",
message="Downloading dataset",
commit=True,
)
assert task.phase_progress == 50.0
assert task.phase_current == 512
assert task.phase_total == 1024
assert task.phase_unit == "bytes"
assert task.phase_message == "Downloading dataset"
mock_db_session.commit.assert_awaited_once()
publish.assert_awaited_once()
class TestTOP500Collector:
"""Tests for TOP500Collector"""

View File

@@ -121,6 +121,25 @@ class TestCollectionTaskModel:
)
assert task.records_processed == 100
def test_task_with_phase_progress(self):
"""Test collection task phase-level progress fields"""
task = CollectionTask(
datasource_id=1,
status="running",
phase="fetching",
phase_progress=42.5,
phase_message="Downloading dataset",
phase_current=1024,
phase_total=4096,
phase_unit="bytes",
)
assert task.phase == "fetching"
assert task.phase_progress == 42.5
assert task.phase_message == "Downloading dataset"
assert task.phase_current == 1024
assert task.phase_total == 4096
assert task.phase_unit == "bytes"
def test_task_error_message(self):
"""Test collection task with error message"""
task = CollectionTask(

View File

@@ -86,6 +86,12 @@ CREATE TABLE collection_tasks (
id BIGSERIAL PRIMARY KEY,
datasource_id INTEGER NOT NULL REFERENCES data_sources(id) ON DELETE CASCADE,
status task_status NOT NULL DEFAULT 'pending',
phase VARCHAR(30) DEFAULT 'queued',
phase_progress FLOAT,
phase_message VARCHAR(255),
phase_current BIGINT,
phase_total BIGINT,
phase_unit VARCHAR(30),
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE,
records_processed INTEGER DEFAULT 0,

View File

@@ -8,6 +8,9 @@ services:
args:
PYTHON_IMAGE: ${PYTHON_IMAGE:-python:3.14-slim}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
env_file:
- ./aiprovider/.env
- ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env}
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -10,6 +10,7 @@ services:
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:latest}
env_file:
- ./aiprovider/.env
- ${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-./aiprovider/.env}
container_name: planet_aiprovider
ports:
- "8010:8010"

View File

@@ -8,6 +8,20 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.45.0] — 2026-04-29
### ✨ Highlights
- 采集任务新增阶段级量化进度,`fetching` 可展示百分比、阶段说明和字节下载量。
- AI Provider 启动链路支持从 `aiprovider/.env``~/.zshrc` 注入运行期配置,并避免密钥/模型变化触发镜像重建。
- AI Provider Docker build context 收敛到服务必需文件,`uv sync` 接入 BuildKit 缓存以减少重复下载。
### 🔧 Improvements
- IPtoASN、OpenGeoFeed、NRO delegated 下载型采集器接入真实字节进度上报。
- 数据源页、采集中任务弹窗和任务历史页展示阶段摘要,并在 tooltip 中保留完整进度细节。
- 调整 Earth 船只默认高度偏移,进一步贴近地表展示。
---
## [0.44.2] — 2026-04-29
### 📝 Documentation

View File

@@ -55,6 +55,30 @@ Parameters:
| `--allow-lan` | Enable LAN access |
| `--verbose` | Show more command output during execution |
### AI Provider Environment and Builds
AI Provider runtime configuration can live in `aiprovider/.env` or in matching variables in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the container at startup.
Changing model, API key, or base URL does not rebuild the image. Restart only AI Provider to pick up runtime configuration changes:
```bash
./planet.sh restart -a
```
For complex shell expansion in `~/.zshrc`, opt in explicitly:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
To ignore `~/.zshrc` during troubleshooting:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
The AI Provider Docker build context is intentionally limited to the files required by the service, and `uv sync` uses a BuildKit cache mount so dependency downloads are reused after the first build.
### Stop
```bash

View File

@@ -24,6 +24,12 @@ This script checks and syncs common dependencies, and generates if missing:
- `aiprovider/.env`
- `frontend/.env.local`
Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the AI Provider container. After changing model, key, or base URL, restart only AI Provider:
```bash
./planet.sh restart -a
```
## 1. Start Services
From the repository root:

View File

@@ -190,8 +190,8 @@
| 正式名称 | 变量名 | 当前值 | 使用位置 / 说明 |
| --- | --- | --- | --- |
| 船只半径偏移 | `VESSEL_CONFIG.altitudeOffset` | `0.56` | 普通 marker 位置 |
| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.7` | 选中船只轨迹线 |
| 船只半径偏移 | `VESSEL_CONFIG.altitudeOffset` | `0.2` | 普通 marker 位置,贴近真实地形基础层 |
| 船只轨迹半径偏移 | `VESSEL_CONFIG.track.altitudeOffset` | `0.22` | 选中船只轨迹线,略高于船只 marker 以保持可见 |
| 船只 renderOrder | local `VESSEL_RENDER_ORDER` | `4.4` | 普通 marker 和交互 overlay |
| 船只轨迹 renderOrder | `VESSEL_RENDER_ORDER - 0.1` | `4.3` | 低于船只 marker |
| 船只点像素尺寸 | local `VESSEL_POINT_SIZE` | `34` | 普通 marker 与 hover / locked overlay 共享尺寸 |

View File

@@ -55,6 +55,52 @@
| `--allow-lan` | 允许局域网访问 |
| `--verbose` | 在执行过程中显示更多命令输出 |
### AI Provider 环境变量和构建
AI Provider 的运行期配置可以放在两处:
| 位置 | 适合内容 | 说明 |
| --- | --- | --- |
| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 会作为 `env_file` 读取 |
| `~/.zshrc` | 个人机器上的 provider、模型、密钥和代理变量 | `planet.sh` 启动时会读取常见的 `AI_*``SERVICE_*``PYTHON_IMAGE``UV_IMAGE`、代理变量 |
推荐写法:
```bash
export AI_PROVIDER=minimax
export AI_PROVIDER_API=anthropic-messages
export AI_BASE_URL=https://api.example.com/anthropic
export AI_API_KEY=sk-change-me
export AI_MODEL=MiniMax-M2.7
export AI_PROVIDER_SERVICE_TOKEN=change_me
```
默认情况下,`planet.sh` 只静态解析 `~/.zshrc` 中简单的 `export KEY=value``KEY=value` 行,避免 shell 主题、插件或交互初始化拖慢启动。如果变量依赖复杂 shell 展开,可以显式启用 source 模式:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
如需完全忽略 `~/.zshrc`
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改 `aiprovider/.env``~/.zshrc` 中的模型、密钥、Base URL 不会触发镜像重建;重启 AI Provider 即可让容器读取新配置:
```bash
./planet.sh restart -a
```
构建较慢时,优先判断当前卡在哪一层:
| 现象 | 常见原因 | 处理方式 |
| --- | --- | --- |
| `transferring context` 很大 | Docker build context 包含前端资源、PDF、数据目录等无关文件 | 当前仓库通过 `.dockerignore` 只发送 AI Provider 必需文件 |
| `uv sync` 下载依赖较慢 | 首次构建或缓存为空,网络访问 Python 包较慢 | 等待首次构建完成;后续会复用 BuildKit 的 uv 下载缓存 |
| 改密钥后仍显示旧配置 | 容器尚未重启 | 执行 `./planet.sh restart -a` |
### 停止
```bash

View File

@@ -46,6 +46,8 @@ compute_ai_provider_build_fingerprint() {
find aiprovider \
-type f \
! -path '*/__pycache__/*' \
! -name '.env' \
! -name '.env.*' \
! -name '*.pyc' \
! -name '*.pyo' \
| LC_ALL=C sort \
@@ -57,6 +59,58 @@ compute_ai_provider_build_fingerprint() {
速度提升约 10 倍大量小文件场景误报率相同mtime+size 变化 ≡ 文件被修改)。
`.env``.env.*` 被排除在 fingerprint 外。它们属于运行期配置,不应该因为修改模型、密钥或 Base URL 触发镜像重建。
### Docker build context 收敛
AI Provider 镜像只需要根目录的 `pyproject.toml``uv.lock``aiprovider/` 代码。仓库中还包含前端静态大图、PDF、历史数据和 Unreal 资料,如果 build context 使用整个仓库,`transferring context` 会浪费大量时间。
当前通过根目录 `.dockerignore` 收敛上下文:
```dockerignore
**
!pyproject.toml
!uv.lock
!aiprovider/
!aiprovider/**
aiprovider/.env
aiprovider/.env.*
!aiprovider/.env.example
```
Dockerfile 也从全仓复制改为只复制 AI Provider 代码:
```dockerfile
COPY pyproject.toml uv.lock /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
COPY aiprovider /app/aiprovider
```
`uv sync` 使用 BuildKit cache mount 后,首次构建仍可能受网络影响;后续构建会复用 `/root/.cache/uv`,依赖下载不再重复从零开始。
### 运行期配置来源
`planet.sh` 启动 AI Provider 前会生成临时 env-file并把它传给 Compose 或手动 `docker run` fallback。配置优先来自
1. `aiprovider/.env`
2. `~/.zshrc` 中简单的 `export AI_...=...``AI_...=...`
默认解析是静态的,只覆盖 AI Provider、镜像、代理相关变量避免执行交互 shell 初始化。如果确实需要复杂 shell 展开,可以显式启用:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
如果排查时需要忽略个人 shell 配置:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
### 跳过重建的原理
fingerprint 一致时不执行 `docker compose build`,而是:
@@ -151,4 +205,7 @@ PY
## 相关文件
- `planet.sh` — 全量修改
- `.dockerignore` — 收敛 AI Provider Docker build context
- `aiprovider/Dockerfile` — 只复制 AI Provider 代码,并为 `uv sync` 启用 BuildKit cache mount
- `docker-compose.yml` / `docker-compose.simple.yml` — 读取 `planet.sh` 生成的运行期 env-file
- `scripts/compute_aiprovider_dependency_fingerprint.py` — 依赖 fingerprint未改动

View File

@@ -24,6 +24,12 @@
- `aiprovider/.env`
- `frontend/.env.local`
AI Provider 的个人配置也可以放在 `~/.zshrc``planet.sh` 会读取简单的 `export AI_...=...``AI_...=...` 行,并在启动 AI Provider 时传给容器。修改模型、密钥或 Base URL 后,通常只需要重启 AI Provider
```bash
./planet.sh restart -a
```
## 1. 启动服务
在仓库根目录执行:

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.44.2`
- `dev` 当前开发分支历史推导到:`0.45.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.45.0` | feature | `dev` | `pending` | 新增采集任务 fetching 阶段量化进度,收敛 AI Provider 运行期环境注入和 Docker build context |
| `0.44.2` | bugfix | `dev` | `pending` | 补充 Earth 船只批量渲染、屏幕拾取、图层顺序、样式参考和性能计划状态文档 |
| `0.44.1` | bugfix | `dev` | `pending` | 优化 Earth 船只批量渲染性能,修复拖动卡顿、拾取错位、交互态方向/尺寸和地表压盖问题 |
| `0.44.0` | feature | `dev` | `pending` | 重构数据源目录与采集器设置,新增 BarentsWatch AIS 连接教程、Earth 船只/缩放体验优化和仪表盘前端重启 |

View File

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

View File

@@ -209,7 +209,7 @@ export const PATHS = {
};
export const VESSEL_CONFIG = {
altitudeOffset: 0.56,
altitudeOffset: 0.2,
maxRenderedMarkers: 5000,
marker: {
baseScale: 7.5,
@@ -232,7 +232,7 @@ export const VESSEL_CONFIG = {
max: 2.4,
},
track: {
altitudeOffset: 0.7,
altitudeOffset: 0.22,
color: 0x7dd3fc,
opacity: 0.82,
},

View File

@@ -30,6 +30,7 @@ import axios, { type AxiosResponse } from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { formatPhaseMetric, getPhaseDisplay, getPhaseSummary } from '../../utils/phaseProgress'
const { Text } = Typography
const COLLECTION_REFRESH_DELAY_MS = 800
@@ -54,6 +55,11 @@ interface BuiltInDataSource {
task_id: number | null
progress: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed: number | null
total_records: number | null
is_free?: boolean
@@ -107,6 +113,11 @@ interface UnifiedDataSource {
is_running?: boolean
progress?: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
task_id?: number | null
created_at?: string
updated_at?: string | null
@@ -129,6 +140,11 @@ type TriggerDatasourceConflict = {
message?: string
progress?: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed?: number | null
total_records?: number | null
}
@@ -142,6 +158,11 @@ type DatasourceTaskStatus = {
task_id?: number | null
progress?: number | null
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed?: number | null
total_records?: number | null
status?: string | null
@@ -166,6 +187,11 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
is_running: source.is_running,
progress: source.progress,
phase: source.phase,
phase_progress: source.phase_progress,
phase_message: source.phase_message,
phase_current: source.phase_current,
phase_total: source.phase_total,
phase_unit: source.phase_unit,
task_id: source.task_id,
is_free: source.is_free,
requires_credentials: source.requires_credentials,
@@ -291,7 +317,7 @@ function DataSources() {
content: (
<div>
<p>{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}</p>
<p>: {taskInfo?.phase || 'running'}</p>
<p>: {getPhaseDisplay(taskInfo || {})}</p>
<p>: {typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知'}</p>
<p></p>
</div>
@@ -474,7 +500,11 @@ function DataSources() {
return <Tag color={record.is_active ? 'green' : 'default'}>{record.is_active ? '启用' : '禁用'}</Tag>
}
if (record.is_running) {
return <Tag color="processing">{record.phase || '运行中'}{record.progress ? ` ${Math.round(record.progress)}%` : ''}</Tag>
return (
<Tooltip title={getPhaseDisplay(record)}>
<Tag color="processing">{getPhaseSummary(record)}</Tag>
</Tooltip>
)
}
if (!record.last_status) return <Tag></Tag>
return <Tag color={record.last_status === 'success' ? 'success' : record.last_status === 'failed' ? 'error' : 'default'}>{record.last_status}</Tag>
@@ -603,8 +633,13 @@ function DataSources() {
{source.source}{source.task_id ? ` · #${source.task_id}` : ''}
</Text>
</Space>
<Tag color="processing">{source.phase || 'running'}</Tag>
<Tooltip title={getPhaseDisplay(source)}>
<Tag color="processing">{getPhaseSummary(source)}</Tag>
</Tooltip>
</div>
{source.phase_message ? (
<Text type="secondary" style={{ fontSize: 12 }}>{source.phase_message}</Text>
) : null}
<Progress
percent={Math.round(source.progress || 0)}
size="small"
@@ -612,8 +647,9 @@ function DataSources() {
strokeColor="#1677ff"
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{source.records_processed ?? 0}
{source.total_records ? ` / ${source.total_records}` : ''}
{source.phase_unit === 'bytes'
? `下载 ${formatPhaseMetric(source) || '准备中'}`
: `已处理 ${source.records_processed ?? 0}${source.total_records ? ` / ${source.total_records}` : ''}`}
</Text>
</Space>
</Card>

View File

@@ -1,16 +1,26 @@
import { useEffect, useState } from 'react'
import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
import { Table, Tag, Card, Row, Col, Statistic, Button, Tooltip } from 'antd'
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
import { useAuthStore } from '../../stores/auth'
import AppLayout from '../../components/AppLayout/AppLayout'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { getPhaseDisplay, getPhaseSummary } from '../../utils/phaseProgress'
interface Task {
id: number
collector: string
collector?: string
datasource_name?: string
status: 'success' | 'failed' | 'running' | 'pending'
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
records_processed: number
total_records?: number | null
progress?: number | null
started_at: string
completed_at: string
duration_seconds: number
@@ -53,7 +63,20 @@ function Tasks() {
title: '收集器',
dataIndex: 'collector',
key: 'collector',
render: (c: string) => <Tag color="blue">{c}</Tag>,
render: (_: string, task: Task) => <Tag color="blue">{task.collector || task.datasource_name || '-'}</Tag>,
},
{
title: '阶段',
dataIndex: 'phase',
key: 'phase',
render: (_: string, task: Task) => {
const detail = task.phase ? getPhaseDisplay(task) : null
return detail ? (
<Tooltip title={detail}>
<Tag color={task.status === 'running' ? 'processing' : 'default'}>{getPhaseSummary(task)}</Tag>
</Tooltip>
) : '-'
},
},
{
title: '状态',

View File

@@ -0,0 +1,61 @@
export type PhaseProgressLike = {
phase?: string | null
phase_progress?: number | null
phase_message?: string | null
phase_current?: number | null
phase_total?: number | null
phase_unit?: string | null
}
const BYTE_UNIT_BASE = 1024
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB'] as const
export function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0 B'
let size = value
let unitIndex = 0
while (size >= BYTE_UNIT_BASE && unitIndex < BYTE_UNITS.length - 1) {
size /= BYTE_UNIT_BASE
unitIndex += 1
}
const digits = unitIndex === 0 ? 0 : size >= 10 ? 1 : 2
return `${size.toFixed(digits)} ${BYTE_UNITS[unitIndex]}`
}
export function formatPhaseMetric(source: Pick<PhaseProgressLike, 'phase_current' | 'phase_total' | 'phase_unit'>): string | null {
const current = source.phase_current
const total = source.phase_total
if (typeof current !== 'number') return null
if (source.phase_unit === 'bytes') {
return typeof total === 'number' && total > 0
? `${formatBytes(current)} / ${formatBytes(total)}`
: formatBytes(current)
}
if (typeof total === 'number' && total > 0) {
return `${current} / ${total}${source.phase_unit ? ` ${source.phase_unit}` : ''}`
}
return `${current}${source.phase_unit ? ` ${source.phase_unit}` : ''}`
}
export function getPhaseDisplay(source: PhaseProgressLike, fallback = 'running'): string {
const phase = source.phase || fallback
const parts = [phase]
if (typeof source.phase_progress === 'number') {
parts.push(`${Math.round(source.phase_progress)}%`)
}
if (source.phase_message) {
parts.push(source.phase_message)
}
const metric = formatPhaseMetric(source)
if (metric) {
parts.push(metric)
}
return parts.join(' · ')
}
export function getPhaseSummary(source: PhaseProgressLike, fallback = 'running'): string {
const phase = source.phase || fallback
return typeof source.phase_progress === 'number'
? `${phase} ${Math.round(source.phase_progress)}%`
: phase
}

View File

@@ -6,6 +6,48 @@ SCRIPT_PATH="${(%):-%N}"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
cd "$SCRIPT_DIR"
load_user_zshrc_exported_env() {
local load_mode="${PLANET_LOAD_ZSHRC_ENV:-1}"
[ "$load_mode" != "0" ] || return 0
[ "$load_mode" != "false" ] || return 0
[ -f "$HOME/.zshrc" ] || return 0
local env_line env_name env_value
if [ "$load_mode" = "source" ]; then
command -v zsh >/dev/null 2>&1 || return 0
while IFS= read -r env_line; do
env_name="${env_line%%=*}"
env_value="${env_line#*=}"
case "$env_name" in
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
export "${env_name}=${env_value}"
;;
esac
done < <(zsh -fc 'source ~/.zshrc >/dev/null 2>&1; env' 2>/dev/null || true)
return 0
fi
while IFS= read -r env_line; do
env_line="$(printf "%s" "$env_line" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
case "$env_line" in
""|\#*) continue ;;
export\ *) env_line="${env_line#export }" ;;
esac
env_name="${env_line%%=*}"
env_value="${env_line#*=}"
[ "$env_name" != "$env_value" ] || continue
env_name="$(printf "%s" "$env_name" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//')"
env_value="$(printf "%s" "$env_value" | sed -E 's/^[[:space:]]+//;s/[[:space:]]+$//;s/^"//;s/"$//;s/^'\''//;s/'\''$//')"
case "$env_name" in
AI_*|SERVICE_NAME|SERVICE_VERSION|PYTHON_IMAGE|UV_IMAGE|DOCKER_BUILDKIT|COMPOSE_DOCKER_CLI_BUILD|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|http_proxy|https_proxy|no_proxy)
export "${env_name}=${env_value}"
;;
esac
done < "$HOME/.zshrc"
}
load_user_zshrc_exported_env
RED='\033[38;5;203m'
GREEN='\033[38;5;114m'
YELLOW='\033[38;5;221m'
@@ -68,7 +110,23 @@ AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-/tmp/planet_aiprovider_runtime.env}"
AI_PROVIDER_RECREATE_REQUIRED=0
AI_PROVIDER_RUNTIME_ENV_NAMES=(
SERVICE_NAME
SERVICE_VERSION
AI_PROVIDER
AI_PROVIDER_API
AI_BASE_URL
AI_API_KEY
AI_MODEL
AI_TIMEOUT_SECONDS
AI_HTTP_RETRY_ATTEMPTS
AI_MAX_TOKENS
AI_ANTHROPIC_VERSION
AI_ANALYSIS_SYSTEM_PROMPT
AI_PROVIDER_SERVICE_TOKEN
)
prepend_path_once() {
local path_entry="$1"
@@ -590,6 +648,8 @@ compute_ai_provider_build_fingerprint() {
find aiprovider \
-type f \
! -path '*/__pycache__/*' \
! -name '.env' \
! -name '.env.*' \
! -name '*.pyc' \
! -name '*.pyo' \
| LC_ALL=C sort \
@@ -633,6 +693,9 @@ run_ai_provider_container_manually() {
if [ -f "$SCRIPT_DIR/aiprovider/.env" ]; then
env_file_args=(--env-file "$SCRIPT_DIR/aiprovider/.env")
fi
if [ -s "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" ]; then
env_file_args+=(--env-file "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE")
fi
docker run -d \
--name "$AI_PROVIDER_CONTAINER_NAME" \
@@ -641,6 +704,22 @@ run_ai_provider_container_manually() {
"$AI_PROVIDER_IMAGE_NAME" >/dev/null
}
write_ai_provider_runtime_env_file() {
local env_name env_value
: > "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
chmod 600 "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE" 2>/dev/null || true
for env_name in "${AI_PROVIDER_RUNTIME_ENV_NAMES[@]}"; do
if [ -n "${(P)env_name+x}" ]; then
env_value="${(P)env_name}"
printf "%s=%s\n" "$env_name" "$env_value" >> "$PLANET_AI_PROVIDER_RUNTIME_ENV_FILE"
fi
done
export PLANET_AI_PROVIDER_RUNTIME_ENV_FILE
}
recreate_ai_provider_container() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
@@ -1156,6 +1235,7 @@ start_ai_provider_service() {
local retry=1
set_wait_detail "启动 AI Provider"
write_ai_provider_runtime_env_file
ensure_ai_provider_image_current
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do

View File

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

2
uv.lock generated
View File

@@ -475,7 +475,7 @@ wheels = [
[[package]]
name = "planet"
version = "0.44.2"
version = "0.45.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },