release: bump version to 0.53.0
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-13 08:05:43 +08:00
parent b87cb310fd
commit d9efd98d26
56 changed files with 2318 additions and 243 deletions

View File

@@ -2,9 +2,14 @@
!pyproject.toml !pyproject.toml
!uv.lock !uv.lock
!VERSION
!backend/
!backend/**
!aiprovider/ !aiprovider/
!aiprovider/** !aiprovider/**
backend/.env
backend/.env.*
aiprovider/.env aiprovider/.env
aiprovider/.env.* aiprovider/.env.*
!aiprovider/.env.example !aiprovider/.env.example

64
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,64 @@
name: ci
on:
push:
branches:
- dev
- main
pull_request:
env:
REGISTRY: gitea.rclaw.top
IMAGE_NAMESPACE: linkong/planet
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Sync Python dependencies
run: ~/.local/bin/uv sync --group dev
- name: Run backend smoke tests
working-directory: backend
run: PYTHONPATH=. "$GITHUB_WORKSPACE/.venv/bin/python" -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Bun
run: curl -fsSL https://bun.sh/install | bash
- name: Build frontend
working-directory: frontend
run: |
~/.bun/bin/bun install --frozen-lockfile
~/.bun/bin/bun run build
delivery:
runs-on: ubuntu-latest
needs:
- backend
- frontend
steps:
- uses: actions/checkout@v4
- name: Install Helm
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz
tar -xzf /tmp/helm.tar.gz -C /tmp
mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Docker build smoke
run: |
docker build -t "$REGISTRY/$IMAGE_NAMESPACE/frontend:${GITHUB_SHA}" ./frontend
docker build -t "$REGISTRY/$IMAGE_NAMESPACE/backend:${GITHUB_SHA}" -f backend/Dockerfile .
docker build -t "$REGISTRY/$IMAGE_NAMESPACE/aiprovider:${GITHUB_SHA}" -f aiprovider/Dockerfile .
- name: Helm template smoke
run: |
helm lint deploy/helm/planet
helm template planet-staging deploy/helm/planet \
--namespace planet-staging \
-f deploy/helm/planet/values.single-node.yaml \
--set image.tag="${GITHUB_SHA}" >/tmp/planet-rendered.yaml

View File

@@ -0,0 +1,67 @@
name: deploy-staging
on:
workflow_dispatch:
push:
branches:
- main
env:
REGISTRY: gitea.rclaw.top
IMAGE_NAMESPACE: linkong/planet
RELEASE_NAME: planet-staging
NAMESPACE: planet-staging
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install deploy tools
run: |
mkdir -p "$HOME/.local/bin"
curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz
tar -xzf /tmp/helm.tar.gz -C /tmp
mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm"
curl -fsSL https://dl.k8s.io/release/v1.30.5/bin/linux/amd64/kubectl -o /tmp/kubectl
install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Configure kubeconfig
run: |
mkdir -p "$HOME/.kube"
printf "%s" "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
- name: Deploy Helm release
run: |
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install "$RELEASE_NAME" deploy/helm/planet \
--namespace "$NAMESPACE" \
-f deploy/helm/planet/values.single-node.yaml \
--set global.imageRegistry="$REGISTRY" \
--set global.imageNamespace="$IMAGE_NAMESPACE" \
--set image.tag="${GITHUB_SHA}"
- name: Wait for rollout
run: |
kubectl rollout status deployment/planet-frontend -n "$NAMESPACE" --timeout=180s
kubectl rollout status deployment/planet-backend -n "$NAMESPACE" --timeout=180s
kubectl rollout status deployment/planet-aiprovider -n "$NAMESPACE" --timeout=180s
- name: Smoke test services
run: |
kubectl run planet-smoke-${GITHUB_RUN_NUMBER} \
--rm -i --restart=Never \
--namespace "$NAMESPACE" \
--image=curlimages/curl:8.11.1 \
--command -- sh -c '
set -eu
curl -fsS http://planet-frontend:3000/ >/dev/null
curl -fsS http://planet-frontend:3000/health >/dev/null
curl -fsS http://planet-frontend:3000/api/health >/dev/null
curl -fsS http://planet-backend:8000/health >/dev/null
curl -fsS http://planet-aiprovider:8010/health >/dev/null
'
- name: Collect diagnostics on failure
if: failure()
run: |
kubectl get all -n "$NAMESPACE" -o wide || true
kubectl describe pods -n "$NAMESPACE" || true
kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE_NAME" --all-containers --tail=200 || true

View File

@@ -0,0 +1,58 @@
name: release
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
env:
REGISTRY: gitea.rclaw.top
IMAGE_NAMESPACE: linkong/planet
jobs:
images:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve image tags
id: meta
run: |
echo "sha_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
if printf "%s" "${GITHUB_REF}" | grep -q '^refs/tags/v'; then
echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
else
echo "release_tag=" >> "$GITHUB_OUTPUT"
fi
- name: Login to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build and push images
run: |
for service in frontend backend aiprovider; do
case "$service" in
frontend)
dockerfile="./frontend/Dockerfile"
context="./frontend"
;;
backend)
dockerfile="backend/Dockerfile"
context="."
;;
aiprovider)
dockerfile="aiprovider/Dockerfile"
context="."
;;
esac
image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.sha_tag }}"
docker build -t "$image" -f "$dockerfile" "$context"
docker push "$image"
if [ -n "${{ steps.meta.outputs.release_tag }}" ]; then
release_image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.release_tag }}"
docker tag "$image" "$release_image"
docker push "$release_image"
fi
done

View File

@@ -365,7 +365,7 @@ DATABASE_RETRY_INTERVAL=10 \
- `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` - `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5`
- `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3` - `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `3`
- `FRONTEND_MAX_RETRIES`: 控制前端 dev server 启动重试次数,默认 `3` - `FRONTEND_MAX_RETRIES`: 控制前端 dev server 启动重试次数,默认 `3`
- `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` - `BACKEND_HEALTH_CHECK_ATTEMPTS` / `BACKEND_HEALTH_CHECK_INTERVAL`: 控制后端 HTTP 健康检查等待次数与间隔,默认 `60` 次、`2`
- `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2` - `FRONTEND_HEALTH_CHECK_ATTEMPTS` / `FRONTEND_HEALTH_CHECK_INTERVAL`: 控制前端 HTTP 可访问检查等待次数与间隔,默认 `10` 次、`2`
- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` - `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2`

View File

@@ -1 +1 @@
0.52.0 0.53.0

View File

@@ -27,4 +27,7 @@ COPY aiprovider /app/aiprovider
EXPOSE 8010 EXPOSE 8010
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010", "--reload"] HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8010/health >/dev/null || exit 1
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "aiprovider.main:app", "--host", "0.0.0.0", "--port", "8010"]

View File

@@ -12,6 +12,7 @@ ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1 ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy ENV UV_LINK_MODE=copy
ENV PYTHONPATH=/app/backend
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
@@ -25,4 +26,7 @@ COPY VERSION /app/VERSION
EXPOSE 8000 EXPOSE 8000
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/health >/dev/null || exit 1
CMD ["uv", "run", "--frozen", "--no-dev", "--project", "/app", "python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -18,6 +18,7 @@ from app.api.v1 import (
vessels, vessels,
bgp, bgp,
news, news,
realtime_sources,
system_control, system_control,
tv, tv,
) )
@@ -50,3 +51,4 @@ api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"]) api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"]) api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(news.router, prefix="/news", tags=["news"]) api_router.include_router(news.router, prefix="/news", tags=["news"])
api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"])

View File

@@ -18,6 +18,8 @@ from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig from app.models.datasource_config import DataSourceConfig
from app.models.task import CollectionTask from app.models.task import CollectionTask
from app.models.user import User from app.models.user import User
from app.models.vessel import AISRawObservation
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA
from app.services.scheduler import ( from app.services.scheduler import (
cancel_running_collector_now, cancel_running_collector_now,
get_latest_task_id_for_datasource, get_latest_task_id_for_datasource,
@@ -161,7 +163,26 @@ async def _load_collected_record_counts(
.where(CollectedData.is_current.is_(True)) .where(CollectedData.is_current.is_(True))
.group_by(CollectedData.source) .group_by(CollectedData.source)
) )
return {source: int(count or 0) for source, count in result.all()} counts = {source: int(count or 0) for source, count in result.all()}
vessel_sources = [
source
for source in sources
if datasource_metadata(source)["credential_provider"] in {"aisstream", "barentswatch"}
or "vessel" in source
or "ais" in source
]
if vessel_sources:
raw_result = await db.execute(
select(AISRawObservation.source, func.count(AISRawObservation.id))
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
.where(AISRawObservation.source.in_(vessel_sources))
.group_by(AISRawObservation.source)
)
for source, count in raw_result.all():
counts[source] = max(counts.get(source, 0), int(count or 0))
return counts
async def _load_datasource_endpoint_overrides( async def _load_datasource_endpoint_overrides(

View File

@@ -117,7 +117,7 @@ async def get_vessel_layer_snapshot(
bbox=parsed_bbox, bbox=parsed_bbox,
zoom=zoom, zoom=zoom,
limit=limit, limit=limit,
vessel_type=vessel_type, type_filter=vessel_type,
since_minutes=since_minutes, since_minutes=since_minutes,
) )

View File

@@ -0,0 +1,280 @@
"""Realtime datasource operations and runtime statistics."""
from datetime import UTC, datetime, timedelta
import os
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.data_sources import get_data_sources_config
from app.core.security import get_current_user
from app.core.time import to_iso8601_utc
from app.db.session import get_db
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.user import User
from app.models.vessel import AISRawObservation, AISSourceHealth
from app.services.custom_datasource_runtime import (
get_custom_stream_status,
start_custom_stream,
stop_custom_stream,
)
from app.services.scheduler import (
cancel_running_collector_now,
is_collector_running,
run_collector_now,
)
from app.services.vessel_ais_aggregation import VESSEL_AIS_SCHEMA, update_ais_source_health
router = APIRouter()
BUILTIN_REALTIME_SOURCES = {"aisstream_vessels"}
REALTIME_SOURCE_TYPES = {"websocket", "ws"}
def _is_realtime_config(config: DataSourceConfig) -> bool:
return str(config.source_type or "").lower() in REALTIME_SOURCE_TYPES
def _safe_config_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _credential_configured(config: DataSourceConfig | None) -> bool:
if config is not None:
auth_config = _safe_config_dict(config.auth_config)
config_payload = _safe_config_dict(config.config)
if auth_config.get("api_key") or config_payload.get("api_key"):
return True
return bool(os.getenv("AISSTREAM_API_KEY"))
async def _load_realtime_stats(db: AsyncSession, source: str) -> dict[str, Any]:
now = datetime.now(UTC)
observed_24h = now - timedelta(hours=24)
observed_1h = now - timedelta(hours=1)
payload_mmsi = AISRawObservation.entity_key
result = await db.execute(
select(
func.count(AISRawObservation.id).label("total_observations"),
func.count(AISRawObservation.id)
.filter(AISRawObservation.observed_at >= observed_24h)
.label("observations_24h"),
func.count(AISRawObservation.id)
.filter(AISRawObservation.observed_at >= observed_1h)
.label("observations_1h"),
func.count(distinct(payload_mmsi)).label("unique_mmsi_total"),
func.count(distinct(payload_mmsi))
.filter(AISRawObservation.observed_at >= observed_24h)
.label("unique_mmsi_24h"),
func.max(AISRawObservation.observed_at).label("latest_observed_at"),
func.max(AISRawObservation.collected_at).label("latest_collected_at"),
)
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
.where(AISRawObservation.source == source)
)
row = result.mappings().one()
return {
"total_observations": int(row["total_observations"] or 0),
"observations_24h": int(row["observations_24h"] or 0),
"observations_1h": int(row["observations_1h"] or 0),
"unique_mmsi_total": int(row["unique_mmsi_total"] or 0),
"unique_mmsi_24h": int(row["unique_mmsi_24h"] or 0),
"latest_observed_at": to_iso8601_utc(row["latest_observed_at"]),
"latest_collected_at": to_iso8601_utc(row["latest_collected_at"]),
}
def _runtime_status_for_builtin(source: str) -> dict[str, Any]:
running = is_collector_running(source)
return {
"running": running,
"done": False,
"runtime": "collector",
}
def _runtime_status_for_custom(config_id: int) -> dict[str, Any]:
status = get_custom_stream_status(config_id)
return {
"running": bool(status.get("running")),
"done": bool(status.get("done")),
"runtime": "custom_stream",
}
async def _serialize_builtin_aisstream(
db: AsyncSession,
datasource: DataSource,
config: DataSourceConfig | None,
) -> dict[str, Any]:
health = await db.get(AISSourceHealth, datasource.source)
config_payload = _safe_config_dict(config.config if config else {})
endpoint = (
(config.endpoint if config else None)
or get_data_sources_config().get_yaml_url(datasource.source)
)
return {
"source": datasource.source,
"name": datasource.name,
"display_name": "AISStream 实时船舶",
"kind": "builtin",
"source_type": "websocket",
"endpoint": endpoint,
"is_active": bool(datasource.is_active),
"credential_configured": _credential_configured(config),
"message_types": config_payload.get("message_types") or ["PositionReport", "ShipStaticData"],
"bounding_boxes": config_payload.get("bounding_boxes") or [[[-90, -180], [90, 180]]],
"config": config_payload,
"runtime": _runtime_status_for_builtin(datasource.source),
"health": health.to_dict() if health else None,
"stats": await _load_realtime_stats(db, datasource.source),
}
async def _serialize_custom_stream(
db: AsyncSession,
config: DataSourceConfig,
) -> dict[str, Any]:
health = await db.get(AISSourceHealth, config.name)
config_payload = _safe_config_dict(config.config)
return {
"source": config.name,
"name": config.name,
"display_name": config.description or config.name,
"kind": "custom",
"config_id": config.id,
"source_type": config.source_type,
"endpoint": config.endpoint,
"is_active": bool(config.is_active),
"credential_configured": config.auth_type == "none" or bool(_safe_config_dict(config.auth_config)),
"message_types": config_payload.get("message_types") or [],
"bounding_boxes": config_payload.get("bounding_boxes") or [],
"config": config_payload,
"runtime": _runtime_status_for_custom(config.id),
"health": health.to_dict() if health else None,
"stats": await _load_realtime_stats(db, config.name),
}
async def _load_builtin_aisstream(db: AsyncSession) -> tuple[DataSource | None, DataSourceConfig | None]:
result = await db.execute(select(DataSource).where(DataSource.source == "aisstream_vessels"))
datasource = result.scalar_one_or_none()
config_result = await db.execute(
select(DataSourceConfig)
.where(DataSourceConfig.name == "aisstream_vessels")
.where(DataSourceConfig.is_active.is_(True))
.order_by(DataSourceConfig.id.desc())
.limit(1)
)
return datasource, config_result.scalar_one_or_none()
async def _load_custom_realtime_config(db: AsyncSession, source: str) -> DataSourceConfig | None:
result = await db.execute(
select(DataSourceConfig)
.where(DataSourceConfig.name == source)
.order_by(DataSourceConfig.id.desc())
.limit(1)
)
config = result.scalar_one_or_none()
return config if config is not None and _is_realtime_config(config) else None
@router.get("")
async def list_realtime_sources(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
sources: list[dict[str, Any]] = []
datasource, builtin_config = await _load_builtin_aisstream(db)
if datasource is not None:
sources.append(await _serialize_builtin_aisstream(db, datasource, builtin_config))
custom_result = await db.execute(
select(DataSourceConfig)
.where(func.lower(DataSourceConfig.source_type).in_(REALTIME_SOURCE_TYPES))
.order_by(DataSourceConfig.name)
)
for config in custom_result.scalars().all():
if config.name in BUILTIN_REALTIME_SOURCES:
continue
sources.append(await _serialize_custom_stream(db, config))
return {"total": len(sources), "data": sources}
async def _ensure_builtin_startable(db: AsyncSession) -> DataSourceConfig | None:
datasource, config = await _load_builtin_aisstream(db)
if datasource is None:
raise HTTPException(status_code=404, detail="Realtime source not found")
if not datasource.is_active:
raise HTTPException(status_code=400, detail="Realtime source is disabled")
if not _credential_configured(config):
raise HTTPException(status_code=400, detail="AISStream API key is not configured")
return config
@router.post("/{source}/start")
async def start_realtime_source(
source: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if source == "aisstream_vessels":
await _ensure_builtin_startable(db)
if is_collector_running(source):
return {"status": "already_running", "source": source, "runtime": _runtime_status_for_builtin(source)}
if not run_collector_now(source):
raise HTTPException(status_code=409, detail="Realtime source could not be started")
return {"status": "started", "source": source, "runtime": _runtime_status_for_builtin(source)}
config = await _load_custom_realtime_config(db, source)
if config is None:
raise HTTPException(status_code=404, detail="Realtime source not found")
if not config.is_active:
raise HTTPException(status_code=400, detail="Realtime source is disabled")
started = start_custom_stream(config.id)
return {
"status": "started" if started else "already_running",
"source": source,
"runtime": _runtime_status_for_custom(config.id),
}
@router.post("/{source}/stop")
async def stop_realtime_source(
source: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if source == "aisstream_vessels":
stopped = await cancel_running_collector_now(source)
await update_ais_source_health(db, source=source, connection_state="disconnected", last_error=None)
await db.commit()
return {"status": "stopped" if stopped else "not_running", "source": source, "runtime": _runtime_status_for_builtin(source)}
config = await _load_custom_realtime_config(db, source)
if config is None:
raise HTTPException(status_code=404, detail="Realtime source not found")
stopped = await stop_custom_stream(config.id)
await update_ais_source_health(db, source=source, connection_state="disconnected", last_error=None)
await db.commit()
return {
"status": "stopped" if stopped else "not_running",
"source": source,
"runtime": _runtime_status_for_custom(config.id),
}
@router.post("/{source}/restart")
async def restart_realtime_source(
source: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await stop_realtime_source(source, current_user=current_user, db=db)
return await start_realtime_source(source, current_user=current_user, db=db)

View File

@@ -68,6 +68,7 @@ TERRAIN_TILE_BATCH_MAX_ITEMS = 128
TERRAIN_TILE_BATCH_CONCURRENCY = 16 TERRAIN_TILE_BATCH_CONCURRENCY = 16
_terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict() _terrain_tile_cache: OrderedDict[tuple[int, int, int], tuple[bytes, str, dict[str, str]]] = OrderedDict()
VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE) VESSEL_NAME_FALLBACK_PATTERN = re.compile(r"^mmsi\s*\d+$", re.IGNORECASE)
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True
class TerrariumTileRequest(BaseModel): class TerrariumTileRequest(BaseModel):
@@ -941,6 +942,39 @@ def _merge_vessel_features(
} }
async def _load_legacy_vessel_snapshot_features(
db: AsyncSession,
*,
bbox: tuple[float, float, float, float] | None,
limit: int,
) -> list[dict[str, Any]]:
latest_positions = select(
VesselPosition.mmsi.label("mmsi"),
func.max(VesselPosition.received_at).label("received_at"),
)
if bbox is not None:
lon_min, lat_min, lon_max, lat_max = bbox
latest_positions = latest_positions.where(VesselPosition.lon >= lon_min)
latest_positions = latest_positions.where(VesselPosition.lon <= lon_max)
latest_positions = latest_positions.where(VesselPosition.lat >= lat_min)
latest_positions = latest_positions.where(VesselPosition.lat <= lat_max)
latest_positions = latest_positions.group_by(VesselPosition.mmsi).subquery()
result = await db.execute(
select(VesselPosition, VesselStatic)
.join(
latest_positions,
(VesselPosition.mmsi == latest_positions.c.mmsi)
& (VesselPosition.received_at == latest_positions.c.received_at),
)
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
.order_by(VesselPosition.received_at.desc())
.limit(limit)
)
legacy_geojson = convert_vessels_to_geojson(list(result.all()))
return legacy_geojson.get("features", [])[:limit]
def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]: def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
by_type: dict[str, int] = {} by_type: dict[str, int] = {}
underway = 0 underway = 0
@@ -2073,33 +2107,6 @@ async def _load_compute_center_record(db: AsyncSession, source_id: str) -> Colle
return result.scalars().first() return result.scalars().first()
@router.get("/geo/vessels")
async def get_vessels_geojson(
bbox: Optional[str] = Query(
None,
description="Viewport bbox as lon_min,lat_min,lon_max,lat_max",
),
type: Optional[str] = Query(
None,
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
),
limit: Optional[int] = Query(
None,
ge=0,
description="Maximum vessel features to return. Omit or pass 0 for no limit.",
),
db: AsyncSession = Depends(get_db),
):
"""Legacy vessel endpoint removed in favor of /api/v1/vessels/snapshot."""
raise HTTPException(
status_code=410,
detail=(
"Legacy vessel GeoJSON endpoint has been removed. "
"Use /api/v1/vessels/snapshot with bbox, zoom, and limit."
),
)
async def _load_raw_vessel_snapshot_features( async def _load_raw_vessel_snapshot_features(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -2121,18 +2128,38 @@ async def _load_raw_vessel_snapshot_features(
observed_since=observed_since, observed_since=observed_since,
) )
raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels) raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels)
features = raw_geojson.get("features", []) raw_features = raw_geojson.get("features", [])
features = raw_features
legacy_features: list[dict[str, Any]] = []
legacy_fallback_used = False
if not raw_features and VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED:
legacy_features = await _load_legacy_vessel_snapshot_features(
db,
bbox=bbox,
limit=limit,
)
features, _merge_diagnostics = _merge_vessel_features(raw_features, legacy_features)
legacy_fallback_used = bool(legacy_features)
return features, { return features, {
"raw_feature_count": len(features), "raw_feature_count": len(raw_features),
"raw_unique_mmsi": len( "raw_unique_mmsi": len(
{ {
key key
for key in (_feature_mmsi_key(feature) for feature in features) for key in (_feature_mmsi_key(feature) for feature in raw_features)
if key is not None if key is not None
} }
), ),
"legacy_feature_count": 0, "legacy_feature_count": len(legacy_features),
"legacy_backfilled_mmsi": 0, "legacy_backfilled_mmsi": len(
{
key
for key in (_feature_mmsi_key(feature) for feature in legacy_features)
if key is not None
}
),
"legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED,
"legacy_fallback_used": legacy_fallback_used,
"final_unique_mmsi": len( "final_unique_mmsi": len(
{ {
key key
@@ -2466,7 +2493,12 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
select(func.count(func.distinct(VesselPosition.mmsi))) select(func.count(func.distinct(VesselPosition.mmsi)))
) )
legacy_unique_mmsi = int(legacy_unique_result.scalar() or 0) legacy_unique_mmsi = int(legacy_unique_result.scalar() or 0)
vessel_count = max(raw_unique_mmsi, legacy_unique_mmsi) legacy_fallback_active = (
VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED
and raw_unique_mmsi == 0
and legacy_unique_mmsi > 0
)
vessel_count = legacy_unique_mmsi if legacy_fallback_active else raw_unique_mmsi
aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels") aisstream_health = await db.get(AISSourceHealth, "aisstream_vessels")
return { return {
@@ -2477,6 +2509,8 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
"satellite_count": satellite_count, "satellite_count": satellite_count,
"compute_center_count": compute_center_count, "compute_center_count": compute_center_count,
"vessel_count": vessel_count, "vessel_count": vessel_count,
"vessel_count_source": "legacy_fallback" if legacy_fallback_active else "raw_recent",
"vessel_legacy_fallback_enabled": VESSEL_SNAPSHOT_LEGACY_FALLBACK_ENABLED,
"vessel_raw_unique_mmsi": raw_unique_mmsi, "vessel_raw_unique_mmsi": raw_unique_mmsi,
"vessel_raw_unique_window_hours": raw_unique_window_hours, "vessel_raw_unique_window_hours": raw_unique_window_hours,
"vessel_legacy_unique_mmsi": legacy_unique_mmsi, "vessel_legacy_unique_mmsi": legacy_unique_mmsi,

View File

@@ -374,6 +374,11 @@ def run_collector_now(collector_name: str) -> bool:
return False return False
def is_collector_running(collector_name: str) -> bool:
task = get_running_collector_task(collector_name)
return bool(task is not None and not task.done())
async def cancel_running_collector_now(collector_name: str) -> bool: async def cancel_running_collector_now(collector_name: str) -> bool:
task = get_running_collector_task(collector_name) task = get_running_collector_task(collector_name)
if task is None or task.done(): if task is None or task.done():

View File

@@ -18,6 +18,17 @@ from app.schemas.ai import (
) )
class _FakeRedisClient:
def sismember(self, *_args, **_kwargs):
return False
@pytest.fixture(autouse=True)
def fake_token_blacklist(monkeypatch):
"""Keep API auth tests independent from an external Redis service."""
monkeypatch.setattr("app.core.security.redis_client", _FakeRedisClient())
@pytest.fixture @pytest.fixture
def auth_headers(): def auth_headers():
"""Create authentication headers""" """Create authentication headers"""
@@ -62,20 +73,43 @@ async def test_dashboard_stats_without_auth():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_dashboard_stats_with_auth(auth_headers): async def test_dashboard_stats_with_auth(auth_headers):
"""Test dashboard stats with authentication""" """Test dashboard stats with authentication"""
with patch("app.api.v1.dashboard.cache.get", return_value=None): class _StatsResult:
with patch("app.api.v1.dashboard.cache.set", return_value=True): def __init__(self, row):
with patch("app.db.session.get_db") as mock_get_db: self._row = row
mock_session = AsyncMock()
mock_result = AsyncMock()
mock_result.scalar.return_value = 0
mock_result.fetchall.return_value = []
mock_session.execute.return_value = mock_result
async def mock_db_context(): def one(self):
yield mock_session return self._row
mock_get_db.return_value = mock_db_context() class _FakeStatsSession:
def __init__(self):
self._rows = [
type("DatasourceStats", (), {"custom_count": 0, "custom_active": 0})(),
type("TaskStats", (), {"tasks_today": 0, "success_tasks": 0})(),
type(
"AlertStats",
(),
{"critical_alerts": 0, "warning_alerts": 0, "info_alerts": 0},
)(),
]
async def execute(self, _query):
return _StatsResult(self._rows.pop(0))
def override_get_current_user():
return User(id=1, username="testuser", email="test@example.com", role="admin", is_active=True)
async def override_get_db():
yield _FakeStatsSession()
app.dependency_overrides.update(
{
__import__("app.core.security", fromlist=["get_current_user"]).get_current_user: override_get_current_user,
get_db: override_get_db,
}
)
try:
with patch("app.api.v1.dashboard.cache.get", return_value=None):
with patch("app.api.v1.dashboard.cache.set", return_value=True):
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get( response = await client.get(
@@ -85,6 +119,8 @@ async def test_dashboard_stats_with_auth(auth_headers):
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "total_datasources" in data assert "total_datasources" in data
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,4 +1,5 @@
from fastapi import HTTPException from fastapi import HTTPException
import pytest
from app.api.v1 import layers from app.api.v1 import layers
@@ -42,3 +43,28 @@ def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit():
assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT
assert result["diagnostics"]["limit_clamped"] is True assert result["diagnostics"]["limit_clamped"] is True
assert result["diagnostics"]["degraded"] is True assert result["diagnostics"]["degraded"] is True
@pytest.mark.asyncio
async def test_vessel_layer_snapshot_passes_type_filter(monkeypatch):
captured = {}
async def fake_build_vessel_snapshot_response(db, **kwargs):
captured.update(kwargs)
return {"type": "FeatureCollection", "features": []}
monkeypatch.setattr(layers, "build_vessel_snapshot_response", fake_build_vessel_snapshot_response)
result = await layers.get_vessel_layer_snapshot(
bbox="10,59,11,60",
zoom=12,
limit=1000,
vessel_type="cargo",
since_minutes=30,
db=object(),
)
assert result["features"] == []
assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0)
assert captured["type_filter"] == "cargo"
assert "vessel_type" not in captured

View File

@@ -0,0 +1,101 @@
from datetime import UTC, datetime
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from app.api.v1 import realtime_sources
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.vessel import AISSourceHealth
@pytest.mark.asyncio
async def test_serialize_builtin_aisstream_includes_health_config_and_stats(monkeypatch):
datasource = DataSource(
id=28,
name="AISStream Vessels",
source="aisstream_vessels",
module="L4",
priority="P1",
collector_class="aisstream_vessels",
is_active=True,
)
config = DataSourceConfig(
id=3,
name="aisstream_vessels",
source_type="websocket",
endpoint="wss://stream.aisstream.io/v0/stream",
auth_type="api_key",
auth_config={"api_key": "test-key"},
config={
"message_types": ["PositionReport"],
"bounding_boxes": [[[-10, 50], [35, 75]]],
},
is_active=True,
)
health = AISSourceHealth(
source="aisstream_vessels",
connection_state="connected",
last_seen_at=datetime(2026, 5, 13, 1, 0, tzinfo=UTC),
)
class _Session:
async def get(self, _model, key):
assert key == "aisstream_vessels"
return health
monkeypatch.setattr(
realtime_sources,
"_load_realtime_stats",
AsyncMock(
return_value={
"total_observations": 10,
"observations_24h": 4,
"observations_1h": 1,
"unique_mmsi_total": 8,
"unique_mmsi_24h": 3,
"latest_observed_at": "2026-05-13T01:00:00Z",
"latest_collected_at": "2026-05-13T01:00:01Z",
}
),
)
monkeypatch.setattr(realtime_sources, "is_collector_running", lambda source: False)
payload = await realtime_sources._serialize_builtin_aisstream(_Session(), datasource, config)
assert payload["source"] == "aisstream_vessels"
assert payload["kind"] == "builtin"
assert payload["credential_configured"] is True
assert payload["message_types"] == ["PositionReport"]
assert payload["runtime"]["running"] is False
assert payload["health"]["connection_state"] == "connected"
assert payload["stats"]["total_observations"] == 10
@pytest.mark.asyncio
async def test_start_builtin_realtime_source_rejects_disabled(monkeypatch):
datasource = DataSource(
id=28,
name="AISStream Vessels",
source="aisstream_vessels",
module="L4",
priority="P1",
collector_class="aisstream_vessels",
is_active=False,
)
monkeypatch.setattr(
realtime_sources,
"_load_builtin_aisstream",
AsyncMock(return_value=(datasource, None)),
)
with pytest.raises(HTTPException) as excinfo:
await realtime_sources.start_realtime_source(
"aisstream_vessels",
current_user=object(),
db=object(),
)
assert excinfo.value.status_code == 400
assert "disabled" in excinfo.value.detail

View File

@@ -574,13 +574,12 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_vessels_geojson_endpoint_is_gone(): async def test_legacy_vessels_geojson_route_is_not_registered():
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/visualization/geo/vessels") response = await client.get("/api/v1/visualization/geo/vessels")
assert response.status_code == 410 assert response.status_code == 404
assert "/api/v1/vessels/snapshot" in response.json()["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -669,15 +668,49 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_legacy_vessels_geojson_rejects_even_with_bbox(): async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(monkeypatch):
transport = ASGITransport(app=app) now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
async with AsyncClient(transport=transport, base_url="http://test") as client: monkeypatch.setattr(
response = await client.get( visualization,
"/api/v1/visualization/geo/vessels", "get_aggregated_vessels_snapshot",
params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000}, AsyncMock(return_value=[]),
) )
monkeypatch.setattr(
visualization,
"_load_legacy_vessel_snapshot_features",
AsyncMock(
return_value=[
{
"type": "Feature",
"id": 257123000,
"geometry": {"type": "Point", "coordinates": [10.73, 59.91]},
"properties": {
"mmsi": 257123000,
"name": "OSLO TRADER",
"vessel_type": 70,
"vessel_type_name": "Cargo",
"received_at": now.isoformat(),
},
}
]
),
)
assert response.status_code == 410 result = await visualization.build_vessel_snapshot_response(
object(),
bbox=(10.0, 59.0, 11.0, 60.0),
zoom=12,
type_filter=None,
limit=1000,
since_minutes=60,
)
assert result["count"] == 1
assert result["features"][0]["properties"]["name"] == "OSLO TRADER"
assert result["diagnostics"]["raw_feature_count"] == 0
assert result["diagnostics"]["legacy_feature_count"] == 1
assert result["diagnostics"]["legacy_backfilled_mmsi"] == 1
assert result["diagnostics"]["legacy_fallback_used"] is True
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: planet
description: Planet situational awareness platform
type: application
version: 0.1.0
appVersion: "0.52.0"

View File

@@ -0,0 +1,33 @@
{{- define "planet.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "planet.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := include "planet.name" . -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- define "planet.labels" -}}
app.kubernetes.io/name: {{ include "planet.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
{{- end -}}
{{- define "planet.selectorLabels" -}}
app.kubernetes.io/name: {{ include "planet.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "planet.image" -}}
{{- printf "%s/%s/%s:%s" .root.Values.global.imageRegistry .root.Values.global.imageNamespace .repository .root.Values.image.tag -}}
{{- end -}}

View File

@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "planet.fullname" . }}-aiprovider
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: aiprovider
spec:
replicas: {{ .Values.aiprovider.replicaCount }}
selector:
matchLabels:
{{- include "planet.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: aiprovider
template:
metadata:
labels:
{{- include "planet.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: aiprovider
spec:
{{- with .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: aiprovider
image: {{ include "planet.image" (dict "root" . "repository" .Values.aiprovider.image.repository) | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8010
envFrom:
- configMapRef:
name: {{ include "planet.fullname" . }}-config
- secretRef:
name: {{ include "planet.fullname" . }}-secrets
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 20
resources:
{{- toYaml .Values.aiprovider.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "planet.fullname" . }}-aiprovider
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: aiprovider
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.aiprovider.service.port }}
targetPort: http
selector:
{{- include "planet.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: aiprovider

View File

@@ -0,0 +1,66 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "planet.fullname" . }}-backend
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
spec:
replicas: {{ .Values.backend.replicaCount }}
selector:
matchLabels:
{{- include "planet.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: backend
template:
metadata:
labels:
{{- include "planet.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: backend
spec:
{{- with .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: backend
image: {{ include "planet.image" (dict "root" . "repository" .Values.backend.image.repository) | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8000
envFrom:
- configMapRef:
name: {{ include "planet.fullname" . }}-config
- secretRef:
name: {{ include "planet.fullname" . }}-secrets
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 20
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "planet.fullname" . }}-backend
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: backend
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.backend.service.port }}
targetPort: http
selector:
{{- include "planet.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: backend

View File

@@ -0,0 +1,27 @@
{{- $postgresHost := .Values.postgresql.external.host -}}
{{- if .Values.postgresql.internal.enabled -}}
{{- $postgresHost = printf "%s-postgresql" (include "planet.fullname" .) -}}
{{- end -}}
{{- $redisHost := .Values.redis.external.host -}}
{{- if .Values.redis.internal.enabled -}}
{{- $redisHost = printf "%s-redis" (include "planet.fullname" .) -}}
{{- end -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "planet.fullname" . }}-config
labels:
{{- include "planet.labels" . | nindent 4 }}
data:
DATABASE_URL: "postgresql+asyncpg://{{ .Values.postgresql.external.username }}:{{ .Values.postgresql.external.password }}@{{ $postgresHost }}:{{ .Values.postgresql.external.port }}/{{ .Values.postgresql.external.database }}"
REDIS_SERVER: {{ $redisHost | quote }}
REDIS_PORT: {{ .Values.redis.external.port | quote }}
REDIS_DB: {{ .Values.redis.external.db | quote }}
AI_BASE_URL: "http://{{ include "planet.fullname" . }}-aiprovider:{{ .Values.aiprovider.service.port }}"
AI_PROVIDER_API: "http://{{ include "planet.fullname" . }}-aiprovider:{{ .Values.aiprovider.service.port }}"
{{- range $key, $value := .Values.backend.env }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- range $key, $value := .Values.aiprovider.env }}
{{ $key }}: {{ $value | quote }}
{{- end }}

View File

@@ -0,0 +1,61 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "planet.fullname" . }}-frontend
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: frontend
spec:
replicas: {{ .Values.frontend.replicaCount }}
selector:
matchLabels:
{{- include "planet.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: frontend
template:
metadata:
labels:
{{- include "planet.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: frontend
spec:
{{- with .Values.global.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: frontend
image: {{ include "planet.image" (dict "root" . "repository" .Values.frontend.image.repository) | quote }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 20
periodSeconds: 20
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "planet.fullname" . }}-frontend
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: frontend
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.frontend.service.port }}
targetPort: http
selector:
{{- include "planet.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: frontend

View File

@@ -0,0 +1,29 @@
{{- if .Values.frontend.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "planet.fullname" . }}
labels:
{{- include "planet.labels" . | nindent 4 }}
{{- with .Values.frontend.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.frontend.ingress.className | quote }}
{{- with .Values.frontend.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ .Values.frontend.ingress.host | quote }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ include "planet.fullname" . }}-frontend
port:
name: http
{{- end }}

View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ include "planet.fullname" . }}-secrets
labels:
{{- include "planet.labels" . | nindent 4 }}
type: Opaque
stringData:
{{- range $key, $value := .Values.backend.secretEnv }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- range $key, $value := .Values.aiprovider.secretEnv }}
{{ $key }}: {{ $value | quote }}
{{- end }}

View File

@@ -0,0 +1,116 @@
{{- if .Values.postgresql.internal.enabled }}
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "planet.fullname" . }}-postgresql
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: postgresql
spec:
serviceName: {{ include "planet.fullname" . }}-postgresql
replicas: 1
selector:
matchLabels:
{{- include "planet.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: postgresql
template:
metadata:
labels:
{{- include "planet.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: postgresql
spec:
containers:
- name: postgresql
image: postgres:15
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_USER
value: {{ .Values.postgresql.external.username | quote }}
- name: POSTGRES_PASSWORD
value: {{ .Values.postgresql.external.password | quote }}
- name: POSTGRES_DB
value: {{ .Values.postgresql.external.database | quote }}
readinessProbe:
exec:
command: ["pg_isready", "-U", {{ .Values.postgresql.external.username | quote }}]
initialDelaySeconds: 5
periodSeconds: 10
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 8Gi
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "planet.fullname" . }}-postgresql
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: postgresql
spec:
ports:
- name: postgres
port: 5432
targetPort: postgres
selector:
{{- include "planet.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: postgresql
{{- end }}
{{- if .Values.redis.internal.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "planet.fullname" . }}-redis
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
{{- include "planet.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
{{- include "planet.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- name: redis
containerPort: 6379
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "planet.fullname" . }}-redis
labels:
{{- include "planet.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
ports:
- name: redis
port: 6379
targetPort: redis
selector:
{{- include "planet.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: redis
{{- end }}

View File

@@ -0,0 +1,29 @@
global:
imageRegistry: gitea.rclaw.top
imageNamespace: linkong/planet
image:
tag: latest
frontend:
ingress:
enabled: true
host: planet.local
postgresql:
internal:
enabled: true
external:
host: planet-postgresql
port: 5432
database: planet_db
username: postgres
password: postgres
redis:
internal:
enabled: true
external:
host: planet-redis
port: 6379
db: 0

View File

@@ -0,0 +1,73 @@
global:
imageRegistry: gitea.rclaw.top
imageNamespace: linkong/planet
imagePullSecrets: []
fullnameOverride: planet
image:
tag: latest
pullPolicy: IfNotPresent
frontend:
replicaCount: 1
image:
repository: frontend
service:
port: 3000
ingress:
enabled: true
className: nginx
host: planet.example.com
annotations: {}
tls: []
resources: {}
backend:
replicaCount: 1
image:
repository: backend
service:
port: 8000
env:
PROJECT_NAME: Planet
CORS_ORIGINS: '["*"]'
secretEnv:
SECRET_KEY: change-me
resources: {}
aiprovider:
replicaCount: 1
image:
repository: aiprovider
service:
port: 8010
env:
AI_PROVIDER: openai
AI_MODEL: gpt-4o-mini
secretEnv:
AI_API_KEY: ""
resources: {}
postgresql:
internal:
enabled: false
external:
host: postgres.example.com
port: 5432
database: planet_db
username: postgres
password: postgres
redis:
internal:
enabled: false
external:
host: redis.example.com
port: 6379
db: 0
smoke:
frontendPath: /
backendHealthPath: /health
aiProviderHealthPath: /health

View File

@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合) - `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1` - `bugfix` -> `+0.0.1`
## [0.53.0] — 2026-05-13
Released: 2026-05-13
### Highlights
- 新增正式交付基线Gitea Actions CI、镜像发布、staging 自动部署,以及 Kubernetes Helm chart。
- 前端生产镜像改为 `vite build` 静态产物 + nginx 托管,后端与 AI Provider 镜像移除开发 reload 并加入容器健康检查。
- 明确 `planet.sh` 只作为开发启动入口,生产环境交给 Kubernetes Service、Ingress、readiness/liveness probe 和 rollout 管理。
### Added / Fixed / Improved
- 新增 frontend/backend/aiprovider 镜像 build smoke、Helm lint/template、staging rollout 与 HTTP smoke test 流水线。
- 新增 Helm values、single-node 演示依赖、ConfigMap/Secret 引用、ClusterIP 服务和 frontend Ingress。
- 更新运维文档与交付计划,保留 Vite 生产构建路线,不新增 Webpack 双构建链Electron 暂不进入主线。
- 继续收口启动脚本风险状态目录、健康检查端口默认值、PID 校验、端口诊断和开发环境跨平台边界说明。
---
## [0.52.0] — 2026-05-12 ## [0.52.0] — 2026-05-12
Released: 2026-05-12 Released: 2026-05-12

View File

@@ -176,7 +176,7 @@ freshness:
## 聚合接口 ## 聚合接口
状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 不再兼容返回数据,而是返回 `410 Gone`;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws``vessels` 订阅。 状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 路由已移除;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws``vessels` 订阅。
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic` 现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`
@@ -346,11 +346,11 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。 5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。
6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。 6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回。
### v3.1 — 聚合完整性修复(已被新快照接口取代) ### v3.1 — 聚合完整性修复(已被受控 fallback 取代)
原目标是先保证“所有已采集到的船都能显示”BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。开发期产品尚未上线后,决策调整为直接淘汰 legacy 船只表兜底:船只快照只读取 `ais_raw_observations` 聚合结果,旧 `vessel_position + vessel_static` 不再合并进 `/api/v1/vessels/snapshot` 原目标是先保证“所有已采集到的船都能显示”BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。当前实现已经移除旧 `/geo/vessels` 路由,船只入口统一为 `/api/v1/vessels/snapshot`。snapshot 优先读取 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,才受控回退到 `vessel_position + vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 标记
因此以下 legacy merge 要求作废,保留在文档中只作为历史决策记录: 因此以下`/geo/vessels` 全量 merge 要求作废,保留在文档中只作为历史决策记录:
1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。 1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons` 2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`
@@ -462,8 +462,8 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags` - 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`
- 同一时间窗口内多来源相近轨迹点只展示一个点。 - 同一时间窗口内多来源相近轨迹点只展示一个点。
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。 - AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
- `/api/v1/vessels/snapshot` 读取 AIS raw observation 聚合结果legacy latest position 不再参与船只快照 - `/api/v1/vessels/snapshot` 优先读取 AIS raw observation 聚合结果;当当前 raw 窗口为空时,允许受控 fallback 到 legacy latest position。
- `/api/v1/visualization/geo/vessels` 返回 `410 Gone`,客户端必须迁移到新 snapshot API。 - `/api/v1/visualization/geo/vessels` 路由已移除,客户端必须迁移到新 snapshot API。
- AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws``vessels` channel 推送增量。 - AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws``vessels` channel 推送增量。
- AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。 - AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。
- `mmsi``imo``callsign` 等身份编号在前端不显示千分位符。 - `mmsi``imo``callsign` 等身份编号在前端不显示千分位符。

View File

@@ -216,7 +216,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再
### 1. 请求视口范围 ### 1. 请求视口范围
前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox``zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone` 前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox``zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 路由已移除
### 2. 后端排序策略 ### 2. 后端排序策略

View File

@@ -120,11 +120,12 @@ CREATE UNIQUE INDEX ON vessel_latest(mmsi);
#### 1.3 API 端点 #### 1.3 API 端点
``` ```http
GET /api/v1/visualization/geo/vessels GET /api/v1/vessels/snapshot
?bbox=lon_min,lat_min,lon_max,lat_max # 视口裁剪 ?bbox=lon_min,lat_min,lon_max,lat_max #
?zoom=12 #
?type=cargo,tanker,passenger # ?type=cargo,tanker,passenger #
?limit=0 # 可选;不传或 0 表示不裁剪数量 ?limit=1000 # 1000 5000
GeoJSON FeatureCollectionPoint GeoJSON FeatureCollectionPoint
GET /api/v1/visualization/vessels/{mmsi} # GET /api/v1/visualization/vessels/{mmsi} #
@@ -163,7 +164,7 @@ GeoJSON Feature 格式:
- 后端 BarentsWatch collector 继续以 HTTP polling 方式采集 - 后端 BarentsWatch collector 继续以 HTTP polling 方式采集
- AISStream 等实时源以独立 WebSocket collector 写入原始观测层 - AISStream 等实时源以独立 WebSocket collector 写入原始观测层
- 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值 - 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值
- 前端默认不再给 `/geo/vessels``limit=5000``VESSEL_CONFIG.maxRenderedMarkers = 0` 表示不做前端数量裁剪;后续如性能不足再引入显式 LOD 上限 - `/api/v1/visualization/geo/vessels` 路由已移除,前端必须使用受控 snapshot 接口。
- marker 颜色、详情卡、hover 和搜索结果必须共享 `vessel_type_display` 船型归一化结果,避免 AIS 数字类型码已驱动颜色但卡片仍显示 `Other` - marker 颜色、详情卡、hover 和搜索结果必须共享 `vessel_type_display` 船型归一化结果,避免 AIS 数字类型码已驱动颜色但卡片仍显示 `Other`
- 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源 - 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源

View File

@@ -0,0 +1,55 @@
# Planet 正式交付与 CI/CD 稳定化计划
## Summary
- CI/CD 平台采用 Gitea Actions由自托管 `act_runner` 执行。
- 正式交付目标采用 Kubernetes端口、健康检查、重启和滚动发布交给 Service、Ingress、readiness/liveness probe。
- Vite 继续保留,但只作为开发服务器和生产构建工具;生产运行 nginx 托管 `vite build` 产物。
- 不新增 Webpack 双构建链。Electron 仅在离线桌面交付成为明确目标后再评估。
## Key Changes
- 生产镜像:
- frontend 多阶段构建,`bun install` + `bun run build`,最终 nginx 托管 `dist`
- backend/aiprovider 移除 `--reload`,加入容器健康检查。
- 镜像标签使用 `<registry>/<namespace>/<service>:<git-sha>`,发布 tag 额外推 `vX.Y.Z`
- Kubernetes
- 新增 Helm chart`deploy/helm/planet`
- frontend 暴露 Ingressbackend/aiprovider 默认 ClusterIP。
- PostgreSQL/Redis 默认外部依赖,`values.single-node.yaml` 提供演示/测试内置依赖。
- Gitea Actions
- `ci.yaml`后端测试、前端构建、Docker build smoke、Helm render。
- `release.yaml`:构建并推送三类镜像。
- `deploy-staging.yaml`:部署 staging、等待 rollout、执行 smoke tests。
- 开发脚本边界:
- `planet.sh` 保留为本地开发便利脚本。
- CI/CD 与正式部署不调用 `planet.sh start`
## Vite / Webpack / Electron Decision
中肯结论:不要因为“企业生产环境”这件事去做 Webpack 版本;继续用 Vite但把“开发服务器”和“生产构建/部署”分清楚。Electron 也不要现在做,除非正式版目标明确是离线桌面软件。
Vite 可以用于生产构建。生产环境运行的是 `vite build` 产出的静态资源,不是 Vite dev server。当前项目已经使用 React + Vite + Bun、`import.meta.env``public/earth` 静态资产路径和大量 Three.js/ES module 资源引用。维护 Webpack 双构建链会显著增加路径、资源、环境变量和回归测试成本。
如果未来客户环境确实要求更接近 Webpack 生态,优先做 Rsbuild/Rspack 技术 spike而不是直接维护 Webpack 并行构建。Electron 适合离线运行、本地硬件/文件访问、系统托盘、自动更新和安装包分发;但 Planet 目前还包含 backend、database、Redis、AI Provider、Motion Agent 等服务编排,桌面壳不能解决正式交付的核心问题。
## Test Plan
- CI gates
- `uv sync --group dev`
- `uv run pytest backend/tests/test_api.py backend/tests/test_realtime_sources.py -q`
- `cd frontend && bun install --frozen-lockfile && bun run build`
- Docker build frontend/backend/aiprovider
- `helm lint deploy/helm/planet`
- `helm template planet-staging deploy/helm/planet -f deploy/helm/planet/values.single-node.yaml`
- Staging deployment:
- `helm upgrade --install planet-staging deploy/helm/planet --namespace planet-staging`
- 等待 frontend/backend/aiprovider rollout。
- smoke test frontend `/`、frontend `/health`、backend `/health`、aiprovider `/health`
## Acceptance Criteria
- main/dev 提交能通过 CI。
- 发布 workflow 能生成可追踪镜像。
- staging 可从零部署并完成滚动升级。
- 正式部署不依赖本机端口清理,也不运行 Vite dev server。

View File

@@ -299,6 +299,17 @@ State semantics:
AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it. AISStream connectivity validation reads the saved collector configuration, environment variables, and `AISSTREAM_API_KEY` in `~/.zshrc` through `datasource_connectivity.py`. For actual collection, the most reliable path is saving the API key in `Settings -> Collector Settings -> AISStream Vessels`; if the key only lives in `~/.zshrc`, confirm that the backend process inherited it.
The console manages AISStream from `/datasources -> Realtime Streams`, not from the normal finite collection progress bar. The realtime stream API aggregates runtime state, health, configuration preview, and raw observation counters:
```http
GET /api/v1/realtime-sources
POST /api/v1/realtime-sources/{source}/start
POST /api/v1/realtime-sources/{source}/stop
POST /api/v1/realtime-sources/{source}/restart
```
`aisstream_vessels` and custom `source_type=websocket` sources appear in that API. They do not participate in one-click collection percentages; the UI interprets them as long-lived services with message counters, lag, last success, and last error.
### AIS Raw Observations And Aggregation ### AIS Raw Observations And Aggregation
AIS observations do not directly replace final vessel records. They are first saved as raw observations: AIS observations do not directly replace final vessel records. They are first saved as raw observations:
@@ -318,7 +329,7 @@ GET /api/v1/visualization/vessels/{mmsi}/track
GET /api/v1/visualization/vessels/{mmsi}/conflicts GET /api/v1/visualization/vessels/{mmsi}/conflicts
``` ```
`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It reads only aggregated `ais_raw_observations`; it no longer merges legacy `vessel_position` / `vessel_static` rows. The old `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. `/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It prefers aggregated `ais_raw_observations`; when the current raw window is empty, it can fall back to the latest legacy `vessel_position` / `vessel_static` rows and marks that path with `diagnostics.legacy_fallback_used`. The old `/api/v1/visualization/geo/vessels` route has been removed.
Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport: Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport:

View File

@@ -288,7 +288,7 @@ Normalization:
Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key. Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key.
Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while the `aisstream_vessels` collector is `streaming` / `connected` and its message count plus `last_seen_at` keep advancing. Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while `aisstream_vessels` is `streaming` / `connected` and its realtime stream counters plus `last_seen_at` keep advancing. Start, stop, reconnect, health, and counters are exposed through `/datasources -> Realtime Streams` and `/api/v1/realtime-sources`; AISStream is not counted in normal one-click collection progress.
The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call: The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call:
@@ -296,7 +296,7 @@ The new vessel list entry point is no longer the legacy `/api/v1/visualization/g
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
``` ```
That endpoint reads local aggregated `ais_raw_observations` only. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI. That endpoint prefers local aggregated `ais_raw_observations`; when the current raw window is empty, it can fall back to the latest legacy `vessel_position` / `vessel_static` rows and exposes that through `diagnostics.legacy_fallback_used`. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI.
## Custom REST / WebSocket Mapping Runtime ## Custom REST / WebSocket Mapping Runtime

View File

@@ -154,7 +154,7 @@ The `earth:compute-center-location-saved` reconciliation pipeline is deliberatel
The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection. The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection.
The legacy `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend no longer merges legacy `vessel_position` / `vessel_static` rows into vessel snapshots, so the frontend must not depend on old BarentsWatch-only fallback rows. The legacy `/api/v1/visualization/geo/vessels` route has been removed. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend only falls back to legacy `vessel_position` / `vessel_static` rows when the current raw window is empty; frontend code can detect that state through `diagnostics.legacy_fallback_used`.
The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport. The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport.

View File

@@ -130,11 +130,12 @@ Steps:
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream` 3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
4. Click the plug icon to test; confirm it reports `Reachable` 4. Click the plug icon to test; confirm it reports `Reachable`
5. Save collector settings 5. Save collector settings
6. Trigger the `aisstream_vessels` collector from the collection scheduler 6. Open the `Realtime Streams` tab on `/datasources` and find `AISStream Realtime Vessels`
7. Watch the `AISStream Runtime` panel: 7. Use `Start`, `Stop`, or `Reconnect` there. The normal `Collection Tasks` tab does not count AISStream in one-click collection or percentage progress
8. Watch the realtime stream panel:
- `streaming` / `connected` means the live stream is being consumed - `streaming` / `connected` means the live stream is being consumed
- `messages this round` should keep growing - `total stored`, `last 24h`, `last 1h`, and `unique MMSI` show historical collection volume
- `disconnected` with `ConnectionResetError` means the upstream or network dropped; re-trigger or wait for reconnect - `disconnected` with a recent error means the upstream or network dropped; click `Reconnect`
## Configure AI Credentials ## Configure AI Credentials
@@ -203,7 +204,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
## Data Exploration ## Data Exploration
- `/datasources`: source directory. It can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list - `/datasources`: source directory. The `Collection Tasks` tab is for one-shot, scheduled, and finite collectors; it can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. The `Realtime Streams` tab is for AISStream / WebSocket long connections and shows connection health, stored totals, time-window counters, and Start / Stop / Reconnect actions. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records" - `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records"
- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth - `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts - `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts

View File

@@ -22,10 +22,10 @@ image_exists AND stamp_non_empty AND fingerprint_match
### Fix ### Fix
The stamp file moved to a persistent cache path: The stamp file moved from a temporary location to a persistent cache path:
```bash ```bash
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" AI_PROVIDER_BUILD_STAMP_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/planet/aiprovider_build.sha256"
``` ```
Writing the stamp creates the directory first: Writing the stamp creates the directory first:
@@ -92,7 +92,7 @@ COPY aiprovider /app/aiprovider
### Runtime Configuration ### Runtime Configuration
Before starting AI Provider, `planet.sh` generates a temporary env-file and passes it to Compose or the manual `docker run` fallback. Configuration priority: Before starting AI Provider, `planet.sh` generates a current-user runtime env-file and passes it to Compose or the manual `docker run` fallback. The default path is `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`. Configuration priority:
1. `aiprovider/.env` 1. `aiprovider/.env`
2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc` 2. simple `export AI_...=...` or `AI_...=...` lines from `~/.zshrc`
@@ -188,6 +188,62 @@ Before the stamp path fix:
After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`. After moving the stamp file, plain `restart` uses the same `stop + start` behavior and the same fingerprint check as `restart -b`.
## State Files, Logs, and Failed-Start Cleanup
`planet.sh` no longer writes PID files, logs, or runtime env-files to fixed `/tmp/planet_*` paths. The default state directory is:
```bash
${XDG_STATE_HOME:-$HOME/.local/state}/planet
```
At startup the script creates this directory and tries to set it to `700`. The current files include:
- `backend.pid` / `frontend.pid` / `motion_agent.pid`
- `backend.log` / `frontend.log` / `motion_agent.log`
- `aiprovider_build.log`
- `aiprovider_runtime.env`
- `ports.env`
PID writes validate that the PID is a positive integer, include a trailing newline, and try to set file mode `600`. PID reads ignore invalid content instead of passing it to `kill`.
After a successful `start`, the script records the ports in `ports.env`. Later `./planet.sh health` calls prefer the last started ports; if the state file is missing, health checks fall back to the defaults `8000`, `3000`, `8010`, and `8765`. This avoids checking default ports after starting with custom ports.
Startup now has light failed-start cleanup. If `start` exits before completing, the script only cleans local processes that this run already started: backend, frontend, and Motion Agent. It does not stop services after a successful start. AI Provider, PostgreSQL, and Redis keep their existing container lifecycle behavior.
## Health Checks and Hardening
HTTP readiness checks now use `curl -fsS --max-time`, so 4xx and 5xx responses are no longer treated as healthy.
Process termination now validates:
- signal names are limited to `TERM`, `KILL`, `INT`, and `HUP`;
- PIDs must be positive integers;
- process group IDs must be positive integers.
This prevents bad PID files or invalid signals from reaching `kill`.
Frontend and Motion Agent startup failures now call `print_port_listener_details()`, matching backend port diagnostics. The Windows-side listener and cleanup path still only runs when WSL is detected.
## Cross-Platform Notes
The script is currently Linux-first with WSL enhancements. Normal Linux runs do not execute the PowerShell path; WSL gets extra Windows listener, portproxy, and camera guidance.
To make this single script fully portable across Linux, macOS, and WSL, the remaining platform differences should be wrapped behind compatibility helpers:
- `stat --format`, `sort -V`, and `xargs -r` are GNU-style and are not fully compatible with default macOS BSD tools.
- `hostname -I`, `ss`, `fuser`, and `systemctl` are usually unavailable on macOS.
- `tac` may be missing on macOS; use `awk` or Python as a fallback.
- Docker Desktop on macOS does not use `systemctl` daemon diagnostics.
- Camera auto-detection relies on `/dev/video*` / `v4l2-ctl`, which is Linux-specific; macOS should use explicit camera URLs or a separate AVFoundation detector.
The recommended direction is a small platform compatibility layer for port listener detection, version comparison, file metadata, reverse tail, LAN IP discovery, and Docker daemon diagnostics, instead of scattering more platform branches throughout service startup logic.
## Production Delivery Boundary
`planet.sh` is a local development convenience script, not the production startup entrypoint. Production delivery should use Kubernetes `Deployment`, `Service`, `Ingress`, and readiness/liveness probes for ports, health checks, restarts, and rolling upgrades. This removes the need for a host script to reclaim local ports and avoids running the Vite dev server in production.
The production frontend shape is `vite build` static output served by nginx/Caddy or an equivalent HTTP server. Do not use `bun run dev` or `vite preview` in production. The project does not maintain a parallel Webpack build chain; if a future enterprise requirement needs closer Webpack-ecosystem compatibility, run an Rsbuild/Rspack spike first. Electron should only be evaluated when the official target becomes an offline desktop application.
## Optional Motion Agent Startup ## Optional Motion Agent Startup
`planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable. `planet.sh` can now manage the local Motion Capture Agent. It is disabled by default so ordinary development machines do not fail startup when cameras, OpenCV, or MediaPipe are unavailable.

View File

@@ -34,7 +34,7 @@ After landing on the `/admin` dashboard, here's a recommended walk-through:
1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first 1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional 2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
3. `/datasources` or `/data`: check whether the collectors have produced data 3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Collection Tasks` for finite collectors, and `/datasources -> Realtime Streams` for AISStream / WebSocket health and counters
4. `/alerts/system`: verify system alerts look right 4. `/alerts/system`: verify system alerts look right
5. `/users` (super_admin only): open accounts for teammates or adjust their groups 5. `/users` (super_admin only): open accounts for teammates or adjust their groups

View File

@@ -326,6 +326,17 @@ AISStream 使用 `wss://stream.aisstream.io/v0/stream` WebSocket endpoint。默
AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。 AISStream 连接验证会通过 `datasource_connectivity.py` 读取保存的采集器配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,最稳妥的方式是把 API Key 保存到“设置 -> 采集器设置 -> AISStream 实时船舶”;如果只放在 `~/.zshrc`,需要确认后端进程实际继承到了该环境变量。
控制台通过 `/datasources -> 实时流` 管理 AISStream而不是把它放进普通有限采集任务的进度条。实时流 API 会聚合运行态、健康状态、配置摘要和 raw observation 计数:
```http
GET /api/v1/realtime-sources
POST /api/v1/realtime-sources/{source}/start
POST /api/v1/realtime-sources/{source}/stop
POST /api/v1/realtime-sources/{source}/restart
```
`aisstream_vessels` 和自定义 `source_type=websocket` 数据源会出现在该接口中。它们不参与一键采集百分比;前端按长连接服务展示消息计数、延迟、最近成功和最近错误。
### AIS 原始观测与聚合 ### AIS 原始观测与聚合
AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw observation AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw observation
@@ -345,7 +356,7 @@ GET /api/v1/visualization/vessels/{mmsi}/track
GET /api/v1/visualization/vessels/{mmsi}/conflicts GET /api/v1/visualization/vessels/{mmsi}/conflicts
``` ```
`/api/v1/vessels/snapshot` 必须携带 `bbox``zoom`,默认 `limit=1000`,最大 `limit=5000`。它消费 `ais_raw_observations` 聚合结果,不再读取 legacy `vessel_position` / `vessel_static` 作为兜底。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone` `/api/v1/vessels/snapshot` 必须携带 `bbox``zoom`,默认 `limit=1000`,最大 `limit=5000`。它优先消费 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,会受控回退到 legacy `vessel_position` / `vessel_static` 最新点,并在 `diagnostics.legacy_fallback_used` 中标明。旧 `/api/v1/visualization/geo/vessels` 路由已移除
实时增量通过 `/ws``vessels` channel 推送。客户端订阅时必须带当前视口: 实时增量通过 `/ws``vessels` channel 推送。客户端订阅时必须带当前视口:

View File

@@ -290,7 +290,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。 连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。
连接验证和正式采集是两个不同动作。设置页出现 `AISStream 凭证已配置WebSocket endpoint 格式有效` 只说明配置可以用于连接;运行状态仍可能是 `disconnected`。只有 `aisstream_vessels` collector 任务处于 `streaming` / `connected`,并且 `本轮消息数``last_seen_at` 持续更新时,全球 AIS 数据才会不断写入本地库。 连接验证和正式采集是两个不同动作。设置页出现 `AISStream 凭证已配置WebSocket endpoint 格式有效` 只说明配置可以用于连接;运行状态仍可能是 `disconnected`。只有 `aisstream_vessels` 处于 `streaming` / `connected`,并且实时流计数`last_seen_at` 持续更新时,全球 AIS 数据才会不断写入本地库。启动、停止、重连、健康状态和计数统一从 `/datasources -> 实时流``/api/v1/realtime-sources` 查看AISStream 不再参与普通一键采集进度。
新版本不再使用 legacy `/api/v1/visualization/geo/vessels` 作为船只列表入口。Earth 初始状态应调用: 新版本不再使用 legacy `/api/v1/visualization/geo/vessels` 作为船只列表入口。Earth 初始状态应调用:
@@ -298,7 +298,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000 GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
``` ```
该接口查询本地 `ais_raw_observations` 聚合结果。实时更新走 `/ws``vessels` channel订阅时必须提供 `bbox``zoom``limit`。服务端按连接过滤 bbox并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。 该接口优先查询本地 `ais_raw_observations` 聚合结果;当当前 raw 窗口为空时,会受控回退到 legacy `vessel_position` / `vessel_static` 最新点,并通过 `diagnostics.legacy_fallback_used` 暴露。实时更新走 `/ws``vessels` channel订阅时必须提供 `bbox``zoom``limit`。服务端按连接过滤 bbox并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。
## 自定义 REST / WebSocket 映射运行时 ## 自定义 REST / WebSocket 映射运行时

View File

@@ -311,7 +311,7 @@ AIS 船只图层入口:
AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment不能在前端凭颜色之外的信息臆造细分类。 AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment不能在前端凭颜色之外的信息臆造细分类。
`/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端不再把 legacy `vessel_position` / `vessel_static` 合并进船只快照,前端也不应依赖旧表里的 BarentsWatch-only 兜底数据 `/api/v1/visualization/geo/vessels` 路由已移除。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端只在当前 raw 窗口为空时受控回退到 legacy `vessel_position` / `vessel_static`,前端可通过 `diagnostics.legacy_fallback_used` 识别该状态
新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox``zoom` 和受控 `limit`,响应会返回 `visible_count``returned_count``diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products``/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。 新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox``zoom` 和受控 `limit`,响应会返回 `visible_count``returned_count``diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products``/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。

View File

@@ -133,11 +133,12 @@
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream` 3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
4. 点击插头图标进行连接测试,确认显示 `可用` 4. 点击插头图标进行连接测试,确认显示 `可用`
5. 保存采集器设置 5. 保存采集器设置
6. 到采集调度入口运行 `aisstream_vessels` collector 6. 打开 `/datasources``实时流` tab找到 `AISStream 实时船舶`
7. `AISStream 运行状态` 中观察: 7. 点击 `启动``停止``重连` 管理长连接;这里不显示百分比进度
- `streaming` / `connected` 表示正在接收实时流 8. 在实时流卡片中观察:
- `本轮消息数` 应持续增长 - `connected` 表示正在接收实时流
- 如果显示 `disconnected` 且错误为 `ConnectionResetError`,需要重新触发或等待重连 - `累计入库``近 24h``近 1h``唯一 MMSI` 用于判断历史采集量
- 如果显示 `disconnected` 且有最近错误,可以点击 `重连`
## 配置 AI 凭证 ## 配置 AI 凭证
@@ -206,7 +207,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
## 数据探索 ## 数据探索
- `/datasources`:数据源目录。可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“采集当前筛选”只触发当前筛选范围。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表 - `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录" - `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
- `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补 - `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
- `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警 - `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警

View File

@@ -22,10 +22,10 @@ image_exists AND stamp_non_empty AND fingerprint_match
### 修复 ### 修复
将戳文件路径从 `/tmp/` 改到持久路径: 将戳文件路径从临时目录改到持久缓存路径:
```bash ```bash
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" AI_PROVIDER_BUILD_STAMP_FILE="${XDG_CACHE_HOME:-$HOME/.cache}/planet/aiprovider_build.sha256"
``` ```
写入时确保目录存在: 写入时确保目录存在:
@@ -94,7 +94,7 @@ COPY aiprovider /app/aiprovider
### 运行期配置来源 ### 运行期配置来源
`planet.sh` 启动 AI Provider 前会生成临时 env-file并把它传给 Compose 或手动 `docker run` fallback。配置优先来自 `planet.sh` 启动 AI Provider 前会生成受当前用户保护的运行期 env-file并把它传给 Compose 或手动 `docker run` fallback。默认路径位于 `${XDG_STATE_HOME:-$HOME/.local/state}/planet/aiprovider_runtime.env`配置优先来自:
1. `aiprovider/.env` 1. `aiprovider/.env`
2. `~/.zshrc` 中简单的 `export AI_...=...``AI_...=...` 2. `~/.zshrc` 中简单的 `export AI_...=...``AI_...=...`
@@ -202,6 +202,62 @@ PY
修复戳文件路径后,无参 `restart` 同样使用 `stop + start`fingerprint 检查正常生效,行为与 `restart -b` 完全一致。无需额外代码变更。 修复戳文件路径后,无参 `restart` 同样使用 `stop + start`fingerprint 检查正常生效,行为与 `restart -b` 完全一致。无需额外代码变更。
## 状态文件、日志与失败清理
`planet.sh` 不再把 PID、日志和运行期 env-file 写入固定 `/tmp/planet_*` 路径。默认状态目录为:
```bash
${XDG_STATE_HOME:-$HOME/.local/state}/planet
```
脚本启动时会创建该目录并尽量设置为 `700`。当前使用的文件包括:
- `backend.pid` / `frontend.pid` / `motion_agent.pid`
- `backend.log` / `frontend.log` / `motion_agent.log`
- `aiprovider_build.log`
- `aiprovider_runtime.env`
- `ports.env`
PID 文件写入前会校验 PID 为正整数,写入时带换行并尽量设置为 `600`。读取 PID 文件时,如果内容不是数字,脚本会忽略该文件,不会把垃圾内容传给 `kill`
`start` 成功后会把本次端口写入 `ports.env`。后续执行 `./planet.sh health` 时,会优先检查上次启动端口;如果没有状态文件,则回退到默认端口 `8000``3000``8010``8765`。这避免了用自定义端口启动后,健康检查仍只看默认端口的问题。
启动过程有轻量失败清理:如果 `start` 中途失败脚本只清理本轮已经拉起的本地进程backend、frontend、Motion Agent不会在正常启动完成后停止服务。AI Provider、PostgreSQL 和 Redis 容器仍按原有容器生命周期管理。
## 健康检查与安全加固
HTTP 健康检查统一使用 `curl -fsS --max-time`。因此 `/health` 返回 4xx/5xx 不再被视为在线。
进程终止路径现在会校验:
- signal 只允许 `TERM``KILL``INT``HUP`
- PID 必须是正整数;
- 进程组 PGID 必须是正整数。
这可以避免坏 PID 文件或错误 signal 造成不可预期的 `kill` 行为。
前端和 Motion Agent 启动失败时,现在也会调用 `print_port_listener_details()`输出与后端一致的端口监听诊断。WSL 下如果端口看起来被 Windows 侧占用,脚本仍只在检测到 WSL 时才调用 PowerShell 诊断或清理路径。
## 跨平台注意事项
当前脚本是 Linux-first并带有 WSL 增强。普通 Linux 不会执行 WSL PowerShell 逻辑WSL 下会额外提供 Windows listener、portproxy 和摄像头提示。
如果要把同一份脚本扩展为 Linux、macOS、WSL 三平台通用,还需要继续封装这些命令差异:
- `stat --format``sort -V``xargs -r` 是 GNU 风格macOS 默认 BSD 工具不完全兼容。
- `hostname -I``ss``fuser``systemctl` 在 macOS 上通常不可用。
- `tac` 在 macOS 上不一定存在,可用 `awk` 或 Python 兜底。
- Docker Desktop on macOS 不适用 `systemctl` daemon 诊断。
- 摄像头自动发现依赖 `/dev/video*` / `v4l2-ctl`,这是 Linux 路线macOS 应显式使用 camera URL 或另做 AVFoundation 检测。
维护方向是增加一个小的 platform compatibility 层,把端口监听检测、版本比较、文件元信息、反向 tail、LAN IP 获取和 Docker daemon 诊断集中处理,而不是在业务启动流程里继续散落平台判断。
## 正式交付边界
`planet.sh` 是本地开发便利脚本,不作为正式生产启动入口。正式交付应通过 Kubernetes 的 `Deployment``Service``Ingress`、readiness/liveness probe 管理端口、健康检查、重启和滚动发布。这样生产环境不需要脚本抢占宿主机端口,也不会依赖 Vite dev server。
前端生产形态是 `vite build` 生成静态资源,再由 nginx/Caddy 等 HTTP 服务器托管。不要在生产中使用 `bun run dev``vite preview`。当前不维护 Webpack 双构建链;如果未来需要评估更企业化的构建生态,优先做 Rsbuild/Rspack spike。Electron 仅在正式目标变成离线桌面软件时再单独评估。
## Motion Agent 可选启动 ## Motion Agent 可选启动
`planet.sh` 现在可以管理本地动作捕捉 Agent但默认不会启动它避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。 `planet.sh` 现在可以管理本地动作捕捉 Agent但默认不会启动它避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。

View File

@@ -34,7 +34,7 @@
1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret 1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
2. `/ai?tab=providers`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选 2. `/ai?tab=providers`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
3. `/datasources``/data`:看采集器是否已经产出数据 3. `/datasources``/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数
4. `/alerts/system`:看系统告警是否正常 4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组 5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组

View File

@@ -16,12 +16,13 @@
## Current Version ## Current Version
- `main` 当前主线历史推导到:`0.16.5` - `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.52.0` - `dev` 当前开发分支历史推导到:`0.53.0`
## Timeline ## Timeline
| Version | Type | Branch | Commit | Summary | | Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `0.53.0` | feature | `dev` | `pending` | 新增 Gitea Actions CI/CD、生产镜像、Kubernetes Helm chart 与 staging 部署 smoke test明确 Vite 生产构建和 planet.sh 开发入口边界 |
| `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 | | `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 |
| `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 | | `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 |
| `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 | | `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 |

6
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.log
coverage
.DS_Store

View File

@@ -1,4 +1,4 @@
FROM oven/bun:1-alpine FROM oven/bun:1-alpine AS build
WORKDIR /app WORKDIR /app
@@ -6,7 +6,16 @@ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
COPY . . COPY . .
RUN bun run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 3000 EXPOSE 3000
CMD ["bun", "run", "dev", "--", "--host", "0.0.0.0"] HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health >/dev/null || exit 1
CMD ["nginx", "-g", "daemon off;"]

56
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,56 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
location = /health {
add_header Content-Type text/plain;
return 200 "ok\n";
}
location = /api/health {
proxy_pass http://planet-backend:8000/health;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://planet-backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://planet-backend:8000/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /index.html {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

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

View File

@@ -259,9 +259,10 @@ const vesselIconLayer = createInteractableLayer({
}); });
const DEFAULT_VESSEL_VIEWPORT = { const DEFAULT_VESSEL_VIEWPORT = {
bbox: [-180, -90, 180, 90], bbox: [-10, 50, 35, 75],
zoom: 2, zoom: 4,
}; };
const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500;
export function getVesselMarkers() { export function getVesselMarkers() {
return vesselIconLayer.getMarkers(); return vesselIconLayer.getMarkers();
@@ -353,8 +354,34 @@ export async function loadVessels(_scene, earth, options = {}) {
}; };
} }
export function startVesselRealtime(earth, { onUpdate } = {}) { function normalizeVesselViewportOptions(options = {}) {
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
? options.bbox.map(Number)
: DEFAULT_VESSEL_VIEWPORT.bbox;
const [lonA, latA, lonB, latB] = bbox;
const normalizedBbox = [
Math.max(-180, Math.min(lonA, lonB)),
Math.max(-90, Math.min(latA, latB)),
Math.min(180, Math.max(lonA, lonB)),
Math.min(90, Math.max(latA, latB)),
];
const area = (normalizedBbox[2] - normalizedBbox[0]) * (normalizedBbox[3] - normalizedBbox[1]);
const safeBbox = Number.isFinite(area) && area <= MAX_VESSEL_SUBSCRIPTION_BBOX_AREA
? normalizedBbox
: DEFAULT_VESSEL_VIEWPORT.bbox;
const zoom = Number.isFinite(Number(options.zoom))
? Number(options.zoom)
: DEFAULT_VESSEL_VIEWPORT.zoom;
return {
bbox: safeBbox,
zoom: Math.max(1, Math.min(20, Math.round(zoom))),
limit: options.limit ?? VESSEL_CONFIG.maxRenderedMarkers,
};
}
export function startVesselRealtime(earth, { onUpdate, bbox, zoom, limit } = {}) {
if (vesselStreamSocket || typeof WebSocket === "undefined") return; if (vesselStreamSocket || typeof WebSocket === "undefined") return;
const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit });
const connect = () => { const connect = () => {
if (!showVessels || vesselStreamSocket) return; if (!showVessels || vesselStreamSocket) return;
const socket = new WebSocket(getVesselStreamUrl()); const socket = new WebSocket(getVesselStreamUrl());
@@ -369,9 +396,9 @@ export function startVesselRealtime(earth, { onUpdate } = {}) {
type: "subscribe", type: "subscribe",
data: { data: {
channel: "vessels", channel: "vessels",
bbox: DEFAULT_VESSEL_VIEWPORT.bbox, bbox: subscriptionOptions.bbox,
zoom: DEFAULT_VESSEL_VIEWPORT.zoom, zoom: subscriptionOptions.zoom,
limit: VESSEL_CONFIG.maxRenderedMarkers, limit: subscriptionOptions.limit,
}, },
})); }));
}; };

View File

@@ -15,6 +15,7 @@ import {
Select, Select,
Space, Space,
Table, Table,
Tabs,
Tag, Tag,
Tooltip, Tooltip,
Typography, Typography,
@@ -84,6 +85,50 @@ interface CustomDataSourceOverride {
updated_at: string | null updated_at: string | null
} }
interface RealtimeSourceHealth {
connection_state: string
last_seen_at: string | null
last_success_at: string | null
last_error: string | null
message_rate: number | null
lag_seconds: number | null
updated_at: string | null
}
interface RealtimeSourceStats {
total_observations: number
observations_24h: number
observations_1h: number
unique_mmsi_total: number
unique_mmsi_24h: number
latest_observed_at: string | null
latest_collected_at: string | null
}
interface RealtimeSourceRuntime {
running: boolean
done: boolean
runtime: string
}
interface RealtimeSource {
source: string
name: string
display_name: string
kind: 'builtin' | 'custom'
config_id?: number
source_type: string
endpoint?: string
is_active: boolean
credential_configured: boolean
message_types: string[]
bounding_boxes: unknown[]
config: Record<string, any>
runtime: RealtimeSourceRuntime
health: RealtimeSourceHealth | null
stats: RealtimeSourceStats
}
interface EditableDataSourceConfig { interface EditableDataSourceConfig {
id: number id: number
name: string name: string
@@ -200,6 +245,10 @@ const PRODUCT_TAG_COLORS: Record<string, string> = {
other: 'default', other: 'default',
} }
function isRealtimeBuiltinSource(source: BuiltInDataSource | UnifiedDataSource) {
return source.source === 'aisstream_vessels'
}
function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource { function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
return { return {
key: `builtin:${source.id}`, key: `builtin:${source.id}`,
@@ -242,7 +291,11 @@ function DataSources() {
const [modal, modalContextHolder] = Modal.useModal() const [modal, modalContextHolder] = Modal.useModal()
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([]) const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
const [customOverrides, setCustomOverrides] = useState<CustomDataSourceOverride[]>([]) const [customOverrides, setCustomOverrides] = useState<CustomDataSourceOverride[]>([])
const [realtimeSources, setRealtimeSources] = useState<RealtimeSource[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [realtimeLoading, setRealtimeLoading] = useState(false)
const [realtimeActionSource, setRealtimeActionSource] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('tasks')
const [triggerAllLoading, setTriggerAllLoading] = useState(false) const [triggerAllLoading, setTriggerAllLoading] = useState(false)
const [forceTriggerAll, setForceTriggerAll] = useState(false) const [forceTriggerAll, setForceTriggerAll] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]) const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
@@ -259,7 +312,11 @@ function DataSources() {
const [tableHeight, setTableHeight] = useState(360) const [tableHeight, setTableHeight] = useState(360)
const tableRegionRef = useRef<HTMLDivElement | null>(null) const tableRegionRef = useRef<HTMLDivElement | null>(null)
const allSources = useMemo(() => builtInSources.map(normalizeBuiltin), [builtInSources]) const taskBuiltInSources = useMemo(
() => builtInSources.filter((source) => !isRealtimeBuiltinSource(source)),
[builtInSources],
)
const allSources = useMemo(() => taskBuiltInSources.map(normalizeBuiltin), [taskBuiltInSources])
const selectedSourceIds = useMemo( const selectedSourceIds = useMemo(
() => selectedRowKeys () => selectedRowKeys
.map((key) => allSources.find((source) => source.key === key)?.id) .map((key) => allSources.find((source) => source.key === key)?.id)
@@ -267,13 +324,13 @@ function DataSources() {
[allSources, selectedRowKeys], [allSources, selectedRowKeys],
) )
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length const activeBuiltInCount = taskBuiltInSources.filter((source) => source.is_active).length
const collectedBuiltInCount = builtInSources.filter((source) => source.has_collected_data).length const collectedBuiltInCount = taskBuiltInSources.filter((source) => source.has_collected_data).length
const runningBuiltInSources = builtInSources.filter((source) => source.is_running) const runningBuiltInSources = taskBuiltInSources.filter((source) => source.is_running)
const runningBuiltInCount = runningBuiltInSources.length const runningBuiltInCount = runningBuiltInSources.length
const aggregateProgress = runningBuiltInCount > 0 const aggregateProgress = runningBuiltInCount > 0
? Math.round( ? Math.round(
builtInSources taskBuiltInSources
.filter((source) => source.is_running) .filter((source) => source.is_running)
.reduce((sum, source) => sum + (source.progress || 0), 0) / runningBuiltInCount, .reduce((sum, source) => sum + (source.progress || 0), 0) / runningBuiltInCount,
) )
@@ -304,10 +361,27 @@ function DataSources() {
} }
}, [activeFilter, collectedFilter, messageApi, moduleFilter, productFilter, searchQuery, statusFilter]) }, [activeFilter, collectedFilter, messageApi, moduleFilter, productFilter, searchQuery, statusFilter])
const fetchRealtimeSources = useCallback(async () => {
setRealtimeLoading(true)
try {
const response = await axios.get('/api/v1/realtime-sources')
setRealtimeSources(response.data.data || [])
} catch (error) {
console.error('Failed to fetch realtime sources:', error)
messageApi.error('获取实时流状态失败')
} finally {
setRealtimeLoading(false)
}
}, [messageApi])
useEffect(() => { useEffect(() => {
void fetchData() void fetchData()
}, [fetchData]) }, [fetchData])
useEffect(() => {
void fetchRealtimeSources()
}, [fetchRealtimeSources])
useEffect(() => { useEffect(() => {
const visibleKeys = new Set(allSources.map((source) => source.key)) const visibleKeys = new Set(allSources.map((source) => source.key))
setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key)))) setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key))))
@@ -324,6 +398,20 @@ function DataSources() {
return () => observer.disconnect() return () => observer.disconnect()
}, [allSources.length]) }, [allSources.length])
const runRealtimeAction = async (source: string, action: 'start' | 'stop' | 'restart') => {
try {
setRealtimeActionSource(`${source}:${action}`)
await axios.post(`/api/v1/realtime-sources/${encodeURIComponent(source)}/${action}`)
messageApi.success(action === 'start' ? '实时流已启动' : action === 'stop' ? '实时流已停止' : '实时流已重连')
await Promise.all([fetchRealtimeSources(), fetchData()])
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '实时流操作失败')
} finally {
setRealtimeActionSource(null)
}
}
const fetchDatasourceTaskStatus = async (id: number) => { const fetchDatasourceTaskStatus = async (id: number) => {
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`) const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`)
return res.data return res.data
@@ -481,6 +569,160 @@ function DataSources() {
} }
} }
const formatCount = (value?: number | null) => Number(value || 0).toLocaleString()
const formatLagSeconds = (value?: number | null) => {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-'
const seconds = Number(value)
if (seconds < 60) return `${Math.round(seconds)}`
if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`
return `${Math.round(seconds / 3600)} 小时`
}
const getConnectionColor = (state?: string | null) => {
if (state === 'connected') return 'success'
if (state === 'connecting' || state === 'reconnecting') return 'processing'
if (state === 'disconnected') return 'default'
return 'warning'
}
const renderConfigPreview = (value: unknown) => {
if (!Array.isArray(value) || value.length === 0) return '-'
return JSON.stringify(value)
}
const realtimeTotals = realtimeSources.reduce(
(acc, source) => ({
running: acc.running + (source.runtime?.running ? 1 : 0),
total: acc.total + (source.stats?.total_observations || 0),
recent24h: acc.recent24h + (source.stats?.observations_24h || 0),
}),
{ running: 0, total: 0, recent24h: 0 },
)
const renderRealtimeSource = (source: RealtimeSource) => {
const state = source.health?.connection_state || (source.runtime?.running ? 'running' : 'disconnected')
const actionBusyPrefix = `${source.source}:`
return (
<Card
key={source.source}
size="small"
title={(
<Space wrap>
<Text strong>{source.display_name || source.name}</Text>
<Tag color={source.kind === 'builtin' ? 'blue' : 'purple'}>{source.kind === 'builtin' ? '内置' : '自定义'}</Tag>
<Tag color={source.is_active ? 'success' : 'default'}>{source.is_active ? '启用' : '禁用'}</Tag>
<Tag color={source.credential_configured ? 'success' : 'warning'}>
{source.credential_configured ? '凭证已配置' : '缺少凭证'}
</Tag>
<Tag color={source.runtime?.running ? 'processing' : 'default'}>
{source.runtime?.running ? '运行中' : '未运行'}
</Tag>
</Space>
)}
extra={(
<Space size={6}>
<Button
size="small"
icon={<PlayCircleOutlined />}
disabled={!source.is_active || source.runtime?.running}
loading={realtimeActionSource === `${source.source}:start`}
onClick={() => { void runRealtimeAction(source.source, 'start') }}
>
</Button>
<Button
size="small"
icon={<PauseCircleOutlined />}
disabled={!source.runtime?.running}
loading={realtimeActionSource === `${source.source}:stop`}
onClick={() => { void runRealtimeAction(source.source, 'stop') }}
>
</Button>
<Button
size="small"
icon={<SyncOutlined />}
loading={realtimeActionSource === `${source.source}:restart` || Boolean(realtimeActionSource?.startsWith(actionBusyPrefix))}
onClick={() => { void runRealtimeAction(source.source, 'restart') }}
>
</Button>
</Space>
)}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Row gutter={[12, 12]}>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div><Tag color={getConnectionColor(source.health?.connection_state)}>{state}</Tag></div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatCount(source.stats?.total_observations)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"> 24h</Text>
<div>{formatCount(source.stats?.observations_24h)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"> 1h</Text>
<div>{formatCount(source.stats?.observations_1h)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"> MMSI</Text>
<div>{formatCount(source.stats?.unique_mmsi_total)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"> 24h MMSI</Text>
<div>{formatCount(source.stats?.unique_mmsi_24h)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(source.health?.last_seen_at)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatLagSeconds(source.health?.lag_seconds)}</div>
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={24} md={12}>
<Text type="secondary">Endpoint</Text>
<Input value={source.endpoint || '-'} readOnly />
</Col>
<Col xs={24} md={12}>
<Text type="secondary">Message Types</Text>
<Input value={source.message_types?.length ? source.message_types.join(', ') : '-'} readOnly />
</Col>
<Col xs={24}>
<Text type="secondary">Bounding Boxes</Text>
<Input value={renderConfigPreview(source.bounding_boxes)} readOnly />
</Col>
</Row>
<Row gutter={[12, 12]}>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(source.health?.last_success_at)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(source.health?.updated_at)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(source.stats?.latest_observed_at)}</div>
</Col>
<Col xs={12} md={6}>
<Text type="secondary"></Text>
<div>{formatDateTimeZhCN(source.stats?.latest_collected_at)}</div>
</Col>
</Row>
{source.health?.last_error ? (
<Alert showIcon type="error" message="最近错误" description={source.health.last_error} />
) : null}
</Space>
</Card>
)
}
const columns = [ const columns = [
{ {
title: '名称', title: '名称',
@@ -591,8 +833,16 @@ function DataSources() {
<div className="page-shell__header"> <div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2> <h2 style={{ margin: 0 }}></h2>
</div> </div>
<div className="page-shell__body data-source-builtin-tab"> <Tabs
<div className="data-source-bulk-toolbar"> activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'tasks',
label: '采集任务',
children: (
<div className="page-shell__body data-source-builtin-tab">
<div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta"> <div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div> <div className="data-source-bulk-toolbar__title"></div>
<div className="data-source-bulk-toolbar__progress"> <div className="data-source-bulk-toolbar__progress">
@@ -609,7 +859,7 @@ function DataSources() {
</div> </div>
<div className="data-source-bulk-toolbar__stat-pill"> <div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span> <span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{builtInSources.length}</strong> <strong>{taskBuiltInSources.length}</strong>
</div> </div>
<div className="data-source-bulk-toolbar__stat-pill"> <div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span> <span className="data-source-bulk-toolbar__stat-label"></span>
@@ -646,10 +896,10 @@ function DataSources() {
<InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} /> <InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} />
</Tooltip> </Tooltip>
<Checkbox checked={forceTriggerAll} onChange={(event) => setForceTriggerAll(event.target.checked)}> <Checkbox checked={forceTriggerAll} onChange={(event) => setForceTriggerAll(event.target.checked)}>
</Checkbox> </Checkbox>
<Button type="primary" size="middle" icon={<SyncOutlined />} loading={triggerAllLoading} onClick={handleTriggerAll}> <Button type="primary" size="middle" icon={<SyncOutlined />} loading={triggerAllLoading} onClick={handleTriggerAll}>
{selectedSourceIds.length ? '采集选中项' : '采集当前筛选'} {selectedSourceIds.length ? '采集选中项' : '一键采集'}
</Button> </Button>
</Space> </Space>
</div> </div>
@@ -741,7 +991,65 @@ function DataSources() {
/> />
<ScrollbarOverlay containerRef={tableRegionRef} targetSelector=".ant-table-body" /> <ScrollbarOverlay containerRef={tableRegionRef} targetSelector=".ant-table-body" />
</div> </div>
</div> </div>
),
},
{
key: 'realtime',
label: '实时流',
children: (
<div className="page-shell__body data-source-builtin-tab">
<div className="data-source-bulk-toolbar">
<div className="data-source-bulk-toolbar__meta">
<div className="data-source-bulk-toolbar__title"></div>
<div className="data-source-bulk-toolbar__progress">
<div className="data-source-bulk-toolbar__progress-copy">
<span></span>
<strong>{realtimeTotals.running}</strong>
</div>
<Progress
percent={realtimeSources.length ? Math.round((realtimeTotals.running / realtimeSources.length) * 100) : 0}
size="small"
status={realtimeTotals.running > 0 ? 'active' : 'normal'}
showInfo={false}
strokeColor="#1677ff"
/>
</div>
<div className="data-source-bulk-toolbar__stats">
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{realtimeSources.length}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{realtimeTotals.running}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{formatCount(realtimeTotals.total)}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"> 24h</span>
<strong>{formatCount(realtimeTotals.recent24h)}</strong>
</div>
</div>
</div>
<Button icon={<SyncOutlined />} loading={realtimeLoading} onClick={() => { void fetchRealtimeSources() }}>
</Button>
</div>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{realtimeSources.length ? realtimeSources.map(renderRealtimeSource) : (
<Card size="small">
<Text type="secondary"></Text>
</Card>
)}
</Space>
</div>
),
},
]}
/>
</div> </div>
<Modal <Modal

440
planet.sh
View File

@@ -1,6 +1,7 @@
#!/usr/bin/env zsh #!/usr/bin/env zsh
set -e set -e
set -o pipefail
SCRIPT_PATH="${(%):-%N}" SCRIPT_PATH="${(%):-%N}"
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
@@ -81,10 +82,12 @@ VERBOSE=0
WAIT_VERBOSE_LOG_FILE="" WAIT_VERBOSE_LOG_FILE=""
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}" WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}" WAIT_VERBOSE_TAIL_LINE_COUNT="${WAIT_VERBOSE_TAIL_LINE_COUNT:-40}"
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-0.2}" WAIT_VERBOSE_LAST_LOG_FILE=""
WAIT_VERBOSE_EMITTED_LINES=0
HTTP_CHECK_MAX_TIME="${HTTP_CHECK_MAX_TIME:-2}"
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}" BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}" BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-60}"
BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}" BACKEND_HEALTH_CHECK_INTERVAL="${BACKEND_HEALTH_CHECK_INTERVAL:-2}"
DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}" DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}"
DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}" DEPENDENCY_INSTALL_RETRY_INTERVAL="${DEPENDENCY_INSTALL_RETRY_INTERVAL:-5}"
@@ -94,6 +97,8 @@ AI_PROVIDER_START_MAX_RETRIES="${AI_PROVIDER_START_MAX_RETRIES:-3}"
AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}" AI_PROVIDER_RETRY_INTERVAL="${AI_PROVIDER_RETRY_INTERVAL:-5}"
DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}" DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}"
DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}" DATABASE_RETRY_INTERVAL="${DATABASE_RETRY_INTERVAL:-5}"
DATABASE_HEALTH_CHECK_ATTEMPTS="${DATABASE_HEALTH_CHECK_ATTEMPTS:-10}"
DATABASE_HEALTH_CHECK_INTERVAL="${DATABASE_HEALTH_CHECK_INTERVAL:-$DATABASE_RETRY_INTERVAL}"
FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}" FRONTEND_MAX_RETRIES="${FRONTEND_MAX_RETRIES:-3}"
FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}" FRONTEND_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}" FRONTEND_HEALTH_CHECK_INTERVAL="${FRONTEND_HEALTH_CHECK_INTERVAL:-2}"
@@ -107,16 +112,27 @@ DEFAULT_AI_PROVIDER_PORT="${DEFAULT_AI_PROVIDER_PORT:-8010}"
DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}" DEFAULT_MOTION_AGENT_PORT="${DEFAULT_MOTION_AGENT_PORT:-8765}"
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}" FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}" FRONTEND_RUNTIME_SOURCE="${FRONTEND_RUNTIME_SOURCE:-}"
FRONTEND_PID_FILE="/tmp/planet_frontend.pid" PLANET_STATE_DIR="${PLANET_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/planet}"
PLANET_CACHE_DIR="${PLANET_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/planet}"
BACKEND_PID_FILE="$PLANET_STATE_DIR/backend.pid"
BACKEND_LOG_FILE="$PLANET_STATE_DIR/backend.log"
FRONTEND_PID_FILE="$PLANET_STATE_DIR/frontend.pid"
FRONTEND_LOG_FILE="$PLANET_STATE_DIR/frontend.log"
PLANET_PORT_STATE_FILE="$PLANET_STATE_DIR/ports.env"
FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js" FRONTEND_VITE_ENTRY="$SCRIPT_DIR/frontend/node_modules/vite/bin/vite.js"
MOTION_AGENT_PID_FILE="/tmp/planet_motion_agent.pid" MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid"
MOTION_AGENT_LOG_FILE="/tmp/planet_motion_agent.log" MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log"
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256" AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256"
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log" AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log"
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}" AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}" 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}" PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-$PLANET_STATE_DIR/aiprovider_runtime.env}"
AI_PROVIDER_RECREATE_REQUIRED=0 AI_PROVIDER_RECREATE_REQUIRED=0
START_RUN_ACTIVE=0
START_RUN_COMPLETED=0
STARTED_BACKEND_THIS_RUN=0
STARTED_FRONTEND_THIS_RUN=0
STARTED_MOTION_AGENT_THIS_RUN=0
AI_PROVIDER_RUNTIME_ENV_NAMES=( AI_PROVIDER_RUNTIME_ENV_NAMES=(
SERVICE_NAME SERVICE_NAME
SERVICE_VERSION SERVICE_VERSION
@@ -133,6 +149,9 @@ AI_PROVIDER_RUNTIME_ENV_NAMES=(
AI_PROVIDER_SERVICE_TOKEN AI_PROVIDER_SERVICE_TOKEN
) )
mkdir -p "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR"
chmod 700 "$PLANET_STATE_DIR" "$PLANET_CACHE_DIR" 2>/dev/null || true
prepend_path_once() { prepend_path_once() {
local path_entry="$1" local path_entry="$1"
[ -n "$path_entry" ] || return 0 [ -n "$path_entry" ] || return 0
@@ -152,6 +171,98 @@ ensure_local_runtime_bins_on_path() {
ensure_local_runtime_bins_on_path ensure_local_runtime_bins_on_path
# Shell / Docker helpers # Shell / Docker helpers
is_pid() {
[[ "${1:-}" =~ ^[0-9]+$ ]] && [ "$1" -gt 0 ]
}
is_safe_signal() {
case "${1:-}" in
TERM|KILL|INT|HUP)
return 0
;;
*)
return 1
;;
esac
}
read_pid_file() {
local pid_file="$1"
local pid=""
[ -f "$pid_file" ] || return 1
pid="$(head -n 1 "$pid_file" 2>/dev/null | tr -d '[:space:]' || true)"
is_pid "$pid" || return 1
printf "%s\n" "$pid"
}
write_pid_file() {
local pid_file="$1"
local pid="$2"
is_pid "$pid" || return 1
mkdir -p "$(dirname "$pid_file")"
printf "%s\n" "$pid" > "$pid_file"
chmod 600 "$pid_file" 2>/dev/null || true
}
remove_pid_file() {
local pid_file="$1"
rm -f "$pid_file"
}
http_ok() {
local url="$1"
curl -fsS --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1
}
log_matches() {
local log_file="$1"
shift
[ -f "$log_file" ] || return 1
[ "$#" -gt 0 ] || return 1
grep -Eiq "$*" "$log_file" 2>/dev/null
}
read_port_state_value() {
local key="$1"
local fallback="$2"
local value=""
if [ -f "$PLANET_PORT_STATE_FILE" ]; then
value="$(sed -nE "s/^${key}=([0-9]+)$/\\1/p" "$PLANET_PORT_STATE_FILE" | tail -n 1)"
fi
if [ -n "$value" ]; then
printf "%s" "$value"
else
printf "%s" "$fallback"
fi
}
write_port_state() {
local backend_port="$1"
local frontend_port="$2"
local ai_provider_port="$3"
local motion_agent_port="$4"
validate_port "$backend_port"
validate_port "$frontend_port"
validate_port "$ai_provider_port"
validate_port "$motion_agent_port"
{
printf "BACKEND_PORT=%s\n" "$backend_port"
printf "FRONTEND_PORT=%s\n" "$frontend_port"
printf "AI_PROVIDER_PORT=%s\n" "$ai_provider_port"
printf "MOTION_AGENT_PORT=%s\n" "$motion_agent_port"
} > "$PLANET_PORT_STATE_FILE"
chmod 600 "$PLANET_PORT_STATE_FILE" 2>/dev/null || true
}
compose_available() { compose_available() {
docker compose version >/dev/null 2>&1 docker compose version >/dev/null 2>&1
} }
@@ -253,36 +364,10 @@ render_wait_spinner() {
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} )) local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}" local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
local detail="${WAIT_SPINNER_DETAIL:- }" local detail="${WAIT_SPINNER_DETAIL:- }"
local verbose_line="" flush_wait_verbose_log
local verbose_lines=()
local line_count=0
local cursor_up=1
if [ "$VERBOSE" -eq 1 ] && [ -n "$WAIT_VERBOSE_LOG_FILE" ] && [ -f "$WAIT_VERBOSE_LOG_FILE" ]; then
while IFS= read -r verbose_line; do
verbose_line="$(sanitize_wait_detail "$verbose_line")"
[ -n "$verbose_line" ] || continue
verbose_lines+=("$verbose_line")
done < <(tail -n "$WAIT_VERBOSE_TAIL_LINE_COUNT" "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null)
while [ "${#verbose_lines[@]}" -gt "$WAIT_VERBOSE_LINE_COUNT" ]; do
verbose_lines=("${verbose_lines[@]:1}")
done
fi
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2 printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&2
printf "\033[1A\r" >&2
if [ "$VERBOSE" -eq 1 ]; then
line_count=1
while [ "$line_count" -le "$WAIT_VERBOSE_LINE_COUNT" ]; do
verbose_line="${verbose_lines[$line_count]:- }"
printf "\n${DIM} %s${NC}\033[K" "$verbose_line" >&2
line_count=$((line_count + 1))
done
cursor_up=$((WAIT_VERBOSE_LINE_COUNT + 1))
fi
printf "\033[%sA\r" "$cursor_up" >&2
} }
animate_wait_spinner() { animate_wait_spinner() {
@@ -305,11 +390,6 @@ animate_wait_spinner() {
clear_wait_spinner() { clear_wait_spinner() {
local line_count=0 local line_count=0
local clear_verbose_lines=0
if [ "$VERBOSE" -eq 1 ] && { [ "$WAIT_SESSION_ACTIVE" -eq 1 ] || [ -n "$WAIT_VERBOSE_LOG_FILE" ]; }; then
clear_verbose_lines=1
fi
printf "\r\033[K" >&2 printf "\r\033[K" >&2
line_count=0 line_count=0
@@ -318,15 +398,8 @@ clear_wait_spinner() {
line_count=$((line_count + 1)) line_count=$((line_count + 1))
done done
if [ "$clear_verbose_lines" -eq 1 ]; then printf "\033[1A\r" >&2
line_count=0 flush_wait_verbose_log
while [ "$line_count" -lt "$WAIT_VERBOSE_LINE_COUNT" ]; do
printf "\n\033[K" >&2
line_count=$((line_count + 1))
done
fi
printf "\033[%sA\r" "$((clear_verbose_lines == 1 ? WAIT_VERBOSE_LINE_COUNT + 1 : 1))" >&2
} }
start_wait_session() { start_wait_session() {
@@ -366,6 +439,8 @@ reset_wait_spinner_state() {
WAIT_SPINNER_DETAIL="" WAIT_SPINNER_DETAIL=""
WAIT_SPINNER_MESSAGE="" WAIT_SPINNER_MESSAGE=""
WAIT_VERBOSE_LOG_FILE="" WAIT_VERBOSE_LOG_FILE=""
WAIT_VERBOSE_LAST_LOG_FILE=""
WAIT_VERBOSE_EMITTED_LINES=0
} }
sanitize_wait_detail() { sanitize_wait_detail() {
@@ -382,6 +457,39 @@ sanitize_wait_detail() {
printf "%s" "$line" printf "%s" "$line"
} }
flush_wait_verbose_log() {
local line=""
local line_number=0
local total_lines=0
[ "$VERBOSE" -eq 1 ] || return 0
[ -n "$WAIT_VERBOSE_LOG_FILE" ] || return 0
[ -f "$WAIT_VERBOSE_LOG_FILE" ] || return 0
if [ "$WAIT_VERBOSE_LAST_LOG_FILE" != "$WAIT_VERBOSE_LOG_FILE" ]; then
WAIT_VERBOSE_LAST_LOG_FILE="$WAIT_VERBOSE_LOG_FILE"
WAIT_VERBOSE_EMITTED_LINES=0
fi
total_lines="$(wc -l < "$WAIT_VERBOSE_LOG_FILE" 2>/dev/null | tr -d '[:space:]')"
[ -n "$total_lines" ] || total_lines=0
if [ "$total_lines" -lt "$WAIT_VERBOSE_EMITTED_LINES" ]; then
WAIT_VERBOSE_EMITTED_LINES=0
fi
[ "$total_lines" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || return 0
printf "\r\033[K" >&2
while IFS= read -r line; do
line_number=$((line_number + 1))
[ "$line_number" -gt "$WAIT_VERBOSE_EMITTED_LINES" ] || continue
line="$(sanitize_wait_detail "$line")"
[ -n "$line" ] || continue
printf "${DIM} %s${NC}\n" "$line" >&2
done < "$WAIT_VERBOSE_LOG_FILE"
WAIT_VERBOSE_EMITTED_LINES="$total_lines"
}
set_wait_detail() { set_wait_detail() {
WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")" WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")"
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
@@ -400,7 +508,7 @@ run_command_with_spinner() {
fi fi
if [ "$VERBOSE" -eq 1 ]; then if [ "$VERBOSE" -eq 1 ]; then
verbose_log_file="$(mktemp /tmp/planet_verbose.XXXXXX.log)" verbose_log_file="$(mktemp "$PLANET_STATE_DIR/verbose.XXXXXX.log")"
WAIT_VERBOSE_LOG_FILE="$verbose_log_file" WAIT_VERBOSE_LOG_FILE="$verbose_log_file"
"$@" > "$verbose_log_file" 2>&1 & "$@" > "$verbose_log_file" 2>&1 &
else else
@@ -494,7 +602,13 @@ log_error() {
} }
log_note() { log_note() {
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
clear_wait_spinner
fi
printf "${DIM} %s${NC}\n" "$1" printf "${DIM} %s${NC}\n" "$1"
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
resume_wait_session
fi
} }
log_success() { log_success() {
@@ -704,7 +818,7 @@ remove_ai_provider_containers() {
local container_ids local container_ids
container_ids="$(list_ai_provider_containers)" container_ids="$(list_ai_provider_containers)"
[ -n "$container_ids" ] || return 0 [ -n "$container_ids" ] || return 0
echo "$container_ids" | xargs -r docker rm -f >/dev/null 2>&1 || true printf "%s\n" "$container_ids" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
} }
run_ai_provider_container_manually() { run_ai_provider_container_manually() {
@@ -1086,7 +1200,7 @@ wait_for_http() {
set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})" set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})"
while [ "$attempt" -le "$attempts" ]; do while [ "$attempt" -le "$attempts" ]; do
if curl -s --max-time "$HTTP_CHECK_MAX_TIME" "$url" > /dev/null 2>&1; then if http_ok "$url"; then
finish_wait_spinner "${service_name}已就绪" finish_wait_spinner "${service_name}已就绪"
return 0 return 0
fi fi
@@ -1179,7 +1293,7 @@ wait_for_frontend_ready() {
fi fi
if [ $(( tick % probe_every_ticks )) -eq 0 ] && if [ $(( tick % probe_every_ticks )) -eq 0 ] &&
curl -s --max-time "$HTTP_CHECK_MAX_TIME" "http://localhost:${frontend_port}" > /dev/null 2>&1; then http_ok "http://localhost:${frontend_port}"; then
if [ "$owns_wait_session" -eq 1 ]; then if [ "$owns_wait_session" -eq 1 ]; then
stop_wait_session stop_wait_session
finish_wait_spinner "前端已就绪" finish_wait_spinner "前端已就绪"
@@ -1289,6 +1403,7 @@ force_cleanup_external_port_listener() {
local saw_no_windows_listener=0 local saw_no_windows_listener=0
local saw_cleanup_failure=0 local saw_cleanup_failure=0
validate_port "$port"
is_wsl_environment || return 1 is_wsl_environment || return 1
command -v powershell.exe >/dev/null 2>&1 || return 1 command -v powershell.exe >/dev/null 2>&1 || return 1
@@ -1378,12 +1493,12 @@ EOF
# Service startup helpers # Service startup helpers
wait_for_database_health() { wait_for_database_health() {
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" && wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL" &&
wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis" wait_for_container_health "planet_redis" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "Redis"
} }
wait_for_postgres_health() { wait_for_postgres_health() {
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL"
} }
start_database_services() { start_database_services() {
@@ -1401,6 +1516,19 @@ start_postgres_service() {
# Backend lifecycle helpers # Backend lifecycle helpers
cleanup_backend_processes() { cleanup_backend_processes() {
local backend_port="${1:-$DEFAULT_BACKEND_PORT}" local backend_port="${1:-$DEFAULT_BACKEND_PORT}"
local tracked_pid=""
if [ -f "$BACKEND_PID_FILE" ]; then
if tracked_pid="$(read_pid_file "$BACKEND_PID_FILE")"; then
terminate_process_group TERM "$tracked_pid"
terminate_process_tree TERM "$tracked_pid"
sleep 1
terminate_process_group KILL "$tracked_pid"
terminate_process_tree KILL "$tracked_pid"
fi
remove_pid_file "$BACKEND_PID_FILE"
fi
terminate_backend_processes TERM "$backend_port" terminate_backend_processes TERM "$backend_port"
if ! wait_for_port_release "$backend_port" 15 0.2; then if ! wait_for_port_release "$backend_port" 15 0.2; then
@@ -1424,11 +1552,12 @@ start_backend_with_retry() {
fail_unreleased_port "$backend_port" fail_unreleased_port "$backend_port"
fi fi
cd "$SCRIPT_DIR/backend" cd "$SCRIPT_DIR/backend"
: > /tmp/planet_backend.log : > "$BACKEND_LOG_FILE"
PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > /tmp/planet_backend.log 2>&1 & PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port "$backend_port" --reload > "$BACKEND_LOG_FILE" 2>&1 &
BACKEND_PID=$! BACKEND_PID=$!
write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID"
if [ "$VERBOSE" -eq 1 ]; then if [ "$VERBOSE" -eq 1 ]; then
WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log" WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE"
fi fi
if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then if wait_for_http "http://localhost:${backend_port}/health" "$BACKEND_HEALTH_CHECK_ATTEMPTS" "$BACKEND_HEALTH_CHECK_INTERVAL" "后端"; then
@@ -1436,14 +1565,14 @@ start_backend_with_retry() {
fi fi
kill "$BACKEND_PID" 2>/dev/null || true kill "$BACKEND_PID" 2>/dev/null || true
if backend_log_indicates_port_conflict "/tmp/planet_backend.log"; then if backend_log_indicates_port_conflict "$BACKEND_LOG_FILE"; then
cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port" cleanup_backend_processes "$backend_port" || fail_unreleased_port "$backend_port"
if wait_for_port_release "$backend_port" 5 0.2; then if wait_for_port_release "$backend_port" 5 0.2; then
log_warn "后端端口冲突已清理,立即重试启动" log_warn "后端端口冲突已清理,立即重试启动"
retry=$((retry + 1)) retry=$((retry + 1))
continue continue
fi fi
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE"
return 1 return 1
fi fi
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL" animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
@@ -1451,7 +1580,7 @@ start_backend_with_retry() {
done done
clear_wait_spinner clear_wait_spinner
report_backend_port_conflict_if_needed "$backend_port" "/tmp/planet_backend.log" || true report_backend_port_conflict_if_needed "$backend_port" "$BACKEND_LOG_FILE" || true
return 1 return 1
} }
@@ -1500,8 +1629,7 @@ ai_provider_service_healthy() {
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}" local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1 docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \ http_ok "http://localhost:${ai_provider_port}/health"
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
} }
ensure_database_services_healthy() { ensure_database_services_healthy() {
@@ -1596,7 +1724,7 @@ start_backend_service() {
ensure_uv_backend_deps ensure_uv_backend_deps
if ! start_backend_with_retry "$backend_port"; then if ! start_backend_with_retry "$backend_port"; then
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES}" log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES}"
tail -10 /tmp/planet_backend.log tail -10 "$BACKEND_LOG_FILE"
exit 1 exit 1
fi fi
} }
@@ -1650,11 +1778,12 @@ terminate_process_group() {
local pid="$2" local pid="$2"
local pgid="" local pgid=""
[ -n "$pid" ] || return 0 is_safe_signal "$signal" || return 1
is_pid "$pid" || return 0
kill -0 "$pid" 2>/dev/null || return 0 kill -0 "$pid" 2>/dev/null || return 0
pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')" pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]')"
[ -n "$pgid" ] || return 0 is_pid "$pgid" || return 0
kill "-${signal}" -- "-${pgid}" 2>/dev/null || true kill "-${signal}" -- "-${pgid}" 2>/dev/null || true
} }
@@ -1665,7 +1794,7 @@ terminate_backend_processes() {
local pids="" local pids=""
local pid="" local pid=""
pids="$(pgrep -f "uvicorn" 2>/dev/null || true)" pids="$(pgrep -f "uvicorn .*--port ${backend_port}" 2>/dev/null || true)"
for pid in $pids; do for pid in $pids; do
terminate_process_group "$signal" "$pid" terminate_process_group "$signal" "$pid"
terminate_process_tree "$signal" "$pid" terminate_process_tree "$signal" "$pid"
@@ -1683,12 +1812,13 @@ terminate_process_tree() {
local pid="$2" local pid="$2"
local child_pid local child_pid
[ -n "$pid" ] || return 0 is_safe_signal "$signal" || return 1
is_pid "$pid" || return 0
kill -0 "$pid" 2>/dev/null || return 0 kill -0 "$pid" 2>/dev/null || return 0
if command -v pgrep >/dev/null 2>&1; then if command -v pgrep >/dev/null 2>&1; then
while IFS= read -r child_pid; do while IFS= read -r child_pid; do
[ -n "$child_pid" ] || continue is_pid "$child_pid" || continue
terminate_process_tree "$signal" "$child_pid" terminate_process_tree "$signal" "$child_pid"
done < <(pgrep -P "$pid" 2>/dev/null || true) done < <(pgrep -P "$pid" 2>/dev/null || true)
fi fi
@@ -1821,15 +1951,13 @@ kill_port_if_requested() {
frontend_log_indicates_port_conflict() { frontend_log_indicates_port_conflict() {
local log_file="$1" local log_file="$1"
[ -f "$log_file" ] || return 1 log_matches "$log_file" "Port .* is already in use"
grep -q "Port .* is already in use" "$log_file" 2>/dev/null
} }
backend_log_indicates_port_conflict() { backend_log_indicates_port_conflict() {
local log_file="$1" local log_file="$1"
[ -f "$log_file" ] || return 1 log_matches "$log_file" "Address already in use|Errno 98"
grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null
} }
print_windows_port_listener_details() { print_windows_port_listener_details() {
@@ -1927,18 +2055,16 @@ cleanup_frontend_processes() {
local tracked_pid="" local tracked_pid=""
if [ -f "$FRONTEND_PID_FILE" ]; then if [ -f "$FRONTEND_PID_FILE" ]; then
tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)" if tracked_pid="$(read_pid_file "$FRONTEND_PID_FILE")"; then
if [ -n "$tracked_pid" ]; then
terminate_process_tree TERM "$tracked_pid" terminate_process_tree TERM "$tracked_pid"
sleep 1 sleep 1
terminate_process_tree KILL "$tracked_pid" terminate_process_tree KILL "$tracked_pid"
fi fi
rm -f "$FRONTEND_PID_FILE" remove_pid_file "$FRONTEND_PID_FILE"
fi fi
pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true pkill -f "${FRONTEND_VITE_ENTRY} --host 0.0.0.0 --port ${frontend_port} --strictPort" 2>/dev/null || true
pkill -f "${FRONTEND_VITE_ENTRY}" 2>/dev/null || true
} }
start_frontend_with_retry() { start_frontend_with_retry() {
@@ -1954,25 +2080,25 @@ start_frontend_with_retry() {
log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认" log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认"
fi fi
cd "$SCRIPT_DIR/frontend" cd "$SCRIPT_DIR/frontend"
: > /tmp/planet_frontend.log : > "$FRONTEND_LOG_FILE"
local -a frontend_args local -a frontend_args
frontend_args=("$FRONTEND_VITE_ENTRY") frontend_args=("$FRONTEND_VITE_ENTRY")
if [ "$frontend_lan_enabled" -eq 1 ]; then if [ "$frontend_lan_enabled" -eq 1 ]; then
frontend_args+=(--host 0.0.0.0) frontend_args+=(--host 0.0.0.0)
fi fi
frontend_args+=(--port "$frontend_port" --strictPort) frontend_args+=(--port "$frontend_port" --strictPort)
nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > /tmp/planet_frontend.log 2>&1 & nohup "$FRONTEND_RUNTIME_BIN" "${frontend_args[@]}" > "$FRONTEND_LOG_FILE" 2>&1 &
FRONTEND_PID=$! FRONTEND_PID=$!
printf "%s" "$FRONTEND_PID" > "$FRONTEND_PID_FILE" write_pid_file "$FRONTEND_PID_FILE" "$FRONTEND_PID"
if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "/tmp/planet_frontend.log" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then if wait_for_frontend_ready "$frontend_port" "$FRONTEND_PID" "$FRONTEND_LOG_FILE" "$FRONTEND_HEALTH_CHECK_ATTEMPTS" "$FRONTEND_HEALTH_CHECK_INTERVAL"; then
return 0 return 0
fi fi
cleanup_frontend_processes "$frontend_port" cleanup_frontend_processes "$frontend_port"
reset_wait_spinner_state reset_wait_spinner_state
if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "/tmp/planet_frontend.log"; then if [ "$frontend_port_requested" -eq 1 ] && frontend_log_indicates_port_conflict "$FRONTEND_LOG_FILE"; then
kill_port_if_requested "$frontend_port" "前端" kill_port_if_requested "$frontend_port" "前端"
fi fi
@@ -2007,7 +2133,8 @@ start_frontend_service() {
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
stop_wait_session stop_wait_session
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}" log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES}"
tail -10 /tmp/planet_frontend.log tail -10 "$FRONTEND_LOG_FILE"
print_port_listener_details "$frontend_port"
exit 1 exit 1
fi fi
stop_wait_session stop_wait_session
@@ -2139,7 +2266,7 @@ cleanup_exit_containers() {
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)" exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
if [ -n "$exit_containers" ]; then if [ -n "$exit_containers" ]; then
log_step "清理残留 Exit 容器" log_step "清理残留 Exit 容器"
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true printf "%s\n" "$exit_containers" | awk '/^[0-9a-f]{12,64}$/' | xargs -r docker rm -f >/dev/null 2>&1 || true
log_success "残留容器已清理" log_success "残留容器已清理"
fi fi
} }
@@ -2157,7 +2284,7 @@ stop_container_if_running() {
} }
stop_backend_service() { stop_backend_service() {
if pgrep -f "uvicorn" >/dev/null 2>&1 || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then if pgrep -f "uvicorn .*--port ${DEFAULT_BACKEND_PORT}" >/dev/null 2>&1 || [ -f "$BACKEND_PID_FILE" ] || ! can_bind_port "$DEFAULT_BACKEND_PORT"; then
cleanup_backend_processes "$DEFAULT_BACKEND_PORT" cleanup_backend_processes "$DEFAULT_BACKEND_PORT"
log_halt "后端服务已停止" log_halt "后端服务已停止"
fi fi
@@ -2168,7 +2295,7 @@ stop_ai_provider_service() {
} }
stop_frontend_service() { stop_frontend_service() {
if pgrep -f "${FRONTEND_VITE_ENTRY}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then if pgrep -f "${FRONTEND_VITE_ENTRY} .*--port ${DEFAULT_FRONTEND_PORT}" >/dev/null 2>&1 || [ -f "$FRONTEND_PID_FILE" ]; then
cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT" cleanup_frontend_processes "$DEFAULT_FRONTEND_PORT"
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
cleanup_frontend_processes "$FRONTEND_PORT" cleanup_frontend_processes "$FRONTEND_PORT"
@@ -2183,11 +2310,39 @@ stop_frontend_service() {
fi fi
} }
print_http_health_status() {
local service_name="$1"
local url="$2"
if http_ok "$url"; then
echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}"
fi
}
print_process_health_status() {
local service_name="$1"
local pattern="$2"
local pid_file="${3:-}"
local tracked_pid=""
if [ -n "$pid_file" ]; then
tracked_pid="$(read_pid_file "$pid_file" || true)"
fi
if pgrep -f "$pattern" >/dev/null 2>&1 || { [ -n "$tracked_pid" ] && kill -0 "$tracked_pid" 2>/dev/null; }; then
echo -e "${DIM} ${service_name}:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} ${service_name}:${NC} ${RED}offline${NC}"
fi
}
motion_agent_pids() { motion_agent_pids() {
local pids="" local pids=""
if [ -f "$MOTION_AGENT_PID_FILE" ]; then if [ -f "$MOTION_AGENT_PID_FILE" ]; then
pids="$(cat "$MOTION_AGENT_PID_FILE" 2>/dev/null || true)" pids="$(read_pid_file "$MOTION_AGENT_PID_FILE" || true)"
if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then if [ -n "$pids" ] && kill -0 "$pids" 2>/dev/null; then
printf "%s\n" "$pids" printf "%s\n" "$pids"
fi fi
@@ -2206,7 +2361,7 @@ cleanup_motion_agent_processes() {
terminate_process_tree "$signal" "$pid" terminate_process_tree "$signal" "$pid"
done < <(motion_agent_pids | awk '!seen[$0]++') done < <(motion_agent_pids | awk '!seen[$0]++')
rm -f "$MOTION_AGENT_PID_FILE" remove_pid_file "$MOTION_AGENT_PID_FILE"
} }
wait_for_motion_agent_ready() { wait_for_motion_agent_ready() {
@@ -2298,7 +2453,7 @@ start_motion_agent_service() {
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 & nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 &
MOTION_AGENT_PID=$! MOTION_AGENT_PID=$!
printf "%s" "$MOTION_AGENT_PID" > "$MOTION_AGENT_PID_FILE" write_pid_file "$MOTION_AGENT_PID_FILE" "$MOTION_AGENT_PID"
if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then if wait_for_motion_agent_ready "$motion_agent_port" "$MOTION_AGENT_PID"; then
log_success "Motion Agent 已就绪" log_success "Motion Agent 已就绪"
@@ -2308,6 +2463,7 @@ start_motion_agent_service() {
log_error "Motion Agent 启动失败" log_error "Motion Agent 启动失败"
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || true
print_port_listener_details "$motion_agent_port"
log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。" log_note "如只需验证 Web 端连接,可加 --motion-agent-dry-run。"
log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。" log_note "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。" log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
@@ -2323,6 +2479,24 @@ stop_motion_agent_service() {
fi fi
} }
cleanup_failed_start() {
[ "$START_RUN_ACTIVE" -eq 1 ] || return 0
[ "$START_RUN_COMPLETED" -eq 0 ] || return 0
clear_wait_spinner
log_warn "启动未完成,清理本轮已拉起的本地进程"
if [ "$STARTED_MOTION_AGENT_THIS_RUN" -eq 1 ]; then
cleanup_motion_agent_processes TERM
fi
if [ "$STARTED_FRONTEND_THIS_RUN" -eq 1 ]; then
cleanup_frontend_processes "${FRONTEND_PORT:-$DEFAULT_FRONTEND_PORT}"
fi
if [ "$STARTED_BACKEND_THIS_RUN" -eq 1 ]; then
cleanup_backend_processes "${BACKEND_PORT:-$DEFAULT_BACKEND_PORT}" || true
fi
}
create_user() { create_user() {
local username local username
local password local password
@@ -2433,14 +2607,27 @@ start() {
parse_service_args "$@" parse_service_args "$@"
cleanup_exit_containers cleanup_exit_containers
START_RUN_ACTIVE=1
START_RUN_COMPLETED=0
STARTED_BACKEND_THIS_RUN=0
STARTED_FRONTEND_THIS_RUN=0
STARTED_MOTION_AGENT_THIS_RUN=0
print_splash print_splash
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT" start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
STARTED_BACKEND_THIS_RUN=1
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED" start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED" "$FRONTEND_LAN_ENABLED"
STARTED_FRONTEND_THIS_RUN=1
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
STARTED_MOTION_AGENT_THIS_RUN=1
fi fi
write_port_state "$BACKEND_PORT" "$FRONTEND_PORT" "$AI_PROVIDER_PORT" "$MOTION_AGENT_PORT"
START_RUN_COMPLETED=1
START_RUN_ACTIVE=0
log_success "启动完成" log_success "启动完成"
log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth" log_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin" log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
@@ -2468,6 +2655,10 @@ stop() {
restart() { restart() {
parse_service_args "$@" parse_service_args "$@"
cleanup_exit_containers cleanup_exit_containers
local state_backend_port=""
local state_frontend_port=""
local state_ai_provider_port=""
local state_motion_agent_port=""
if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then if [ "$BACKEND_PORT_REQUESTED" -eq 0 ] && [ "$FRONTEND_PORT_REQUESTED" -eq 0 ] && [ "$AI_PROVIDER_REQUESTED" -eq 0 ] && [ "$MOTION_AGENT_REQUESTED" -eq 0 ] && [ "$DATABASE_REQUESTED" -eq 0 ]; then
stop stop
@@ -2506,6 +2697,24 @@ restart() {
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED" start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
fi fi
state_backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")"
state_frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")"
state_ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")"
state_motion_agent_port="$(read_port_state_value MOTION_AGENT_PORT "$DEFAULT_MOTION_AGENT_PORT")"
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
state_backend_port="$BACKEND_PORT"
fi
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
state_frontend_port="$FRONTEND_PORT"
fi
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
state_ai_provider_port="$AI_PROVIDER_PORT"
fi
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
state_motion_agent_port="$MOTION_AGENT_PORT"
fi
write_port_state "$state_backend_port" "$state_frontend_port" "$state_ai_provider_port" "$state_motion_agent_port"
echo "" echo ""
log_success "重启完成" log_success "重启完成"
if [ "$DATABASE_REQUESTED" -eq 1 ]; then if [ "$DATABASE_REQUESTED" -eq 1 ]; then
@@ -2529,34 +2738,23 @@ restart() {
} }
health() { health() {
local backend_port=""
local frontend_port=""
local ai_provider_port=""
backend_port="$(read_port_state_value BACKEND_PORT "$DEFAULT_BACKEND_PORT")"
frontend_port="$(read_port_state_value FRONTEND_PORT "$DEFAULT_FRONTEND_PORT")"
ai_provider_port="$(read_port_state_value AI_PROVIDER_PORT "$DEFAULT_AI_PROVIDER_PORT")"
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || log_note "Docker daemon 不可用,跳过容器状态"
echo "" echo ""
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}" echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
if curl -s http://localhost:8000/health > /dev/null 2>&1; then print_http_health_status "后端" "http://localhost:${backend_port}/health"
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}" print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health"
else print_http_health_status "前端" "http://localhost:${frontend_port}"
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}" print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE"
fi
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
fi
if curl -s http://localhost:3000 > /dev/null 2>&1; then
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
fi
if pgrep -f "python.*-m motion_agent" >/dev/null 2>&1 || [ -f "$MOTION_AGENT_PID_FILE" ]; then
echo -e "${DIM} Motion Agent:${NC} ${GREEN}online${NC}"
else
echo -e "${DIM} Motion Agent:${NC} ${RED}offline${NC}"
fi
} }
log() { log() {
@@ -2564,12 +2762,12 @@ log() {
-f|--frontend) -f|--frontend)
log_step "查看前端日志" log_step "查看前端日志"
log_note "按 Ctrl+C 退出" log_note "按 Ctrl+C 退出"
tail -f /tmp/planet_frontend.log tail -f "$FRONTEND_LOG_FILE"
;; ;;
-b|--backend) -b|--backend)
log_step "查看后端日志" log_step "查看后端日志"
log_note "按 Ctrl+C 退出" log_note "按 Ctrl+C 退出"
tail -f /tmp/planet_backend.log tail -f "$BACKEND_LOG_FILE"
;; ;;
-a|--ai-provider) -a|--ai-provider)
log_step "查看 AI Provider 日志" log_step "查看 AI Provider 日志"
@@ -2584,11 +2782,11 @@ log() {
*) *)
log_step "查看最近日志" log_step "查看最近日志"
log_note "后端" log_note "后端"
tail -20 /tmp/planet_backend.log 2>/dev/null || log_note "无日志" tail -20 "$BACKEND_LOG_FILE" 2>/dev/null || log_note "无日志"
log_note "AI Provider" log_note "AI Provider"
docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志" docker logs --tail 20 planet_aiprovider 2>/dev/null || log_note "无日志"
log_note "前端" log_note "前端"
tail -20 /tmp/planet_frontend.log 2>/dev/null || log_note "无日志" tail -20 "$FRONTEND_LOG_FILE" 2>/dev/null || log_note "无日志"
log_note "Motion Agent" log_note "Motion Agent"
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志" tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志"
;; ;;
@@ -2611,6 +2809,8 @@ parse_global_args() {
GLOBAL_ARG_REMAINDER=("$@") GLOBAL_ARG_REMAINDER=("$@")
} }
trap cleanup_failed_start EXIT
parse_global_args "$@" parse_global_args "$@"
set -- "${GLOBAL_ARG_REMAINDER[@]}" set -- "${GLOBAL_ARG_REMAINDER[@]}"
@@ -2637,7 +2837,7 @@ case "$1" in
;; ;;
*) *)
log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}" log_error "用法: ./planet.sh {start|stop|restart|createuser|health|log}"
log_note "全局参数: -v, --verbose 在当前执行行下方滚动显示最多 5 行命令输出" log_note "全局参数: -v, --verbose 在状态提示之间增量输出命令日志"
log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose" log_note "start 启动服务,可选: -b <后端端口> -f <前端端口> -a <AI Provider 端口> -m/--motion-agent --motion-agent-port <端口> --motion-agent-camera-indexes 0,1 --motion-agent-camera-urls rtsp://... --motion-agent-dry-run --allow-lan --verbose"
log_note "stop 停止服务" log_note "stop 停止服务"
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose" log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"

View File

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

2
uv.lock generated
View File

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