dev #11
@@ -2,9 +2,14 @@
|
||||
|
||||
!pyproject.toml
|
||||
!uv.lock
|
||||
!VERSION
|
||||
!backend/
|
||||
!backend/**
|
||||
!aiprovider/
|
||||
!aiprovider/**
|
||||
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
aiprovider/.env
|
||||
aiprovider/.env.*
|
||||
!aiprovider/.env.example
|
||||
|
||||
64
.gitea/workflows/ci.yaml
Normal file
64
.gitea/workflows/ci.yaml
Normal 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
|
||||
67
.gitea/workflows/deploy-staging.yaml
Normal file
67
.gitea/workflows/deploy-staging.yaml
Normal 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
|
||||
58
.gitea/workflows/release.yaml
Normal file
58
.gitea/workflows/release.yaml
Normal 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
|
||||
@@ -365,7 +365,7 @@ DATABASE_RETRY_INTERVAL=10 \
|
||||
- `AI_PROVIDER_START_MAX_RETRIES` / `AI_PROVIDER_RETRY_INTERVAL`: 控制 `aiprovider` 的构建/启动与容器重启自愈,默认 `3` 次、`5` 秒
|
||||
- `BACKEND_MAX_RETRIES`: 控制后端进程启动重试次数,默认 `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` 秒
|
||||
- `AI_PROVIDER_HEALTH_CHECK_ATTEMPTS` / `AI_PROVIDER_HEALTH_CHECK_INTERVAL`: 控制 `aiprovider` HTTP 健康检查等待次数与间隔,默认 `10` 次、`2` 秒
|
||||
|
||||
|
||||
@@ -27,4 +27,7 @@ COPY aiprovider /app/aiprovider
|
||||
|
||||
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"]
|
||||
|
||||
@@ -12,6 +12,7 @@ ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
ENV PYTHONPATH=/app/backend
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
@@ -25,4 +26,7 @@ COPY VERSION /app/VERSION
|
||||
|
||||
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"]
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.api.v1 import (
|
||||
vessels,
|
||||
bgp,
|
||||
news,
|
||||
realtime_sources,
|
||||
system_control,
|
||||
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(tv.router, prefix="/tv", tags=["tv"])
|
||||
api_router.include_router(news.router, prefix="/news", tags=["news"])
|
||||
api_router.include_router(realtime_sources.router, prefix="/realtime-sources", tags=["realtime-sources"])
|
||||
|
||||
@@ -18,6 +18,8 @@ from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.task import CollectionTask
|
||||
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 (
|
||||
cancel_running_collector_now,
|
||||
get_latest_task_id_for_datasource,
|
||||
@@ -161,7 +163,26 @@ async def _load_collected_record_counts(
|
||||
.where(CollectedData.is_current.is_(True))
|
||||
.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(
|
||||
|
||||
@@ -117,7 +117,7 @@ async def get_vessel_layer_snapshot(
|
||||
bbox=parsed_bbox,
|
||||
zoom=zoom,
|
||||
limit=limit,
|
||||
vessel_type=vessel_type,
|
||||
type_filter=vessel_type,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
|
||||
|
||||
280
backend/app/api/v1/realtime_sources.py
Normal file
280
backend/app/api/v1/realtime_sources.py
Normal 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)
|
||||
@@ -68,6 +68,7 @@ TERRAIN_TILE_BATCH_MAX_ITEMS = 128
|
||||
TERRAIN_TILE_BATCH_CONCURRENCY = 16
|
||||
_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_SNAPSHOT_LEGACY_FALLBACK_ENABLED = True
|
||||
|
||||
|
||||
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]:
|
||||
by_type: dict[str, int] = {}
|
||||
underway = 0
|
||||
@@ -2073,33 +2107,6 @@ async def _load_compute_center_record(db: AsyncSession, source_id: str) -> Colle
|
||||
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(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
@@ -2121,18 +2128,38 @@ async def _load_raw_vessel_snapshot_features(
|
||||
observed_since=observed_since,
|
||||
)
|
||||
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, {
|
||||
"raw_feature_count": len(features),
|
||||
"raw_feature_count": len(raw_features),
|
||||
"raw_unique_mmsi": len(
|
||||
{
|
||||
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
|
||||
}
|
||||
),
|
||||
"legacy_feature_count": 0,
|
||||
"legacy_backfilled_mmsi": 0,
|
||||
"legacy_feature_count": len(legacy_features),
|
||||
"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(
|
||||
{
|
||||
key
|
||||
@@ -2466,7 +2493,12 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
||||
select(func.count(func.distinct(VesselPosition.mmsi)))
|
||||
)
|
||||
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")
|
||||
|
||||
return {
|
||||
@@ -2477,6 +2509,8 @@ async def get_visualization_geo_summary(db: AsyncSession = Depends(get_db)):
|
||||
"satellite_count": satellite_count,
|
||||
"compute_center_count": compute_center_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_window_hours": raw_unique_window_hours,
|
||||
"vessel_legacy_unique_mmsi": legacy_unique_mmsi,
|
||||
|
||||
@@ -374,6 +374,11 @@ def run_collector_now(collector_name: str) -> bool:
|
||||
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:
|
||||
task = get_running_collector_task(collector_name)
|
||||
if task is None or task.done():
|
||||
|
||||
@@ -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
|
||||
def auth_headers():
|
||||
"""Create authentication headers"""
|
||||
@@ -62,20 +73,43 @@ async def test_dashboard_stats_without_auth():
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_stats_with_auth(auth_headers):
|
||||
"""Test dashboard stats with authentication"""
|
||||
with patch("app.api.v1.dashboard.cache.get", return_value=None):
|
||||
with patch("app.api.v1.dashboard.cache.set", return_value=True):
|
||||
with patch("app.db.session.get_db") as mock_get_db:
|
||||
mock_session = AsyncMock()
|
||||
mock_result = AsyncMock()
|
||||
mock_result.scalar.return_value = 0
|
||||
mock_result.fetchall.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
class _StatsResult:
|
||||
def __init__(self, row):
|
||||
self._row = row
|
||||
|
||||
async def mock_db_context():
|
||||
yield mock_session
|
||||
def one(self):
|
||||
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)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
@@ -85,6 +119,8 @@ async def test_dashboard_stats_with_auth(auth_headers):
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "total_datasources" in data
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
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_clamped"] 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
|
||||
|
||||
101
backend/tests/test_realtime_sources.py
Normal file
101
backend/tests/test_realtime_sources.py
Normal 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
|
||||
@@ -574,13 +574,12 @@ async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
|
||||
|
||||
|
||||
@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)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/api/v1/visualization/geo/vessels")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert "/api/v1/vessels/snapshot" in response.json()["detail"]
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -669,15 +668,49 @@ async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_vessels_geojson_rejects_even_with_bbox():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get(
|
||||
"/api/v1/visualization/geo/vessels",
|
||||
params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000},
|
||||
)
|
||||
async def test_vessel_snapshot_uses_legacy_fallback_when_raw_window_is_empty(monkeypatch):
|
||||
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(
|
||||
visualization,
|
||||
"get_aggregated_vessels_snapshot",
|
||||
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
|
||||
|
||||
6
deploy/helm/planet/Chart.yaml
Normal file
6
deploy/helm/planet/Chart.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: planet
|
||||
description: Planet situational awareness platform
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.52.0"
|
||||
33
deploy/helm/planet/templates/_helpers.tpl
Normal file
33
deploy/helm/planet/templates/_helpers.tpl
Normal 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 -}}
|
||||
66
deploy/helm/planet/templates/aiprovider.yaml
Normal file
66
deploy/helm/planet/templates/aiprovider.yaml
Normal 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
|
||||
66
deploy/helm/planet/templates/backend.yaml
Normal file
66
deploy/helm/planet/templates/backend.yaml
Normal 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
|
||||
27
deploy/helm/planet/templates/configmap.yaml
Normal file
27
deploy/helm/planet/templates/configmap.yaml
Normal 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 }}
|
||||
61
deploy/helm/planet/templates/frontend.yaml
Normal file
61
deploy/helm/planet/templates/frontend.yaml
Normal 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
|
||||
29
deploy/helm/planet/templates/ingress.yaml
Normal file
29
deploy/helm/planet/templates/ingress.yaml
Normal 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 }}
|
||||
14
deploy/helm/planet/templates/secrets.yaml
Normal file
14
deploy/helm/planet/templates/secrets.yaml
Normal 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 }}
|
||||
116
deploy/helm/planet/templates/single-node-deps.yaml
Normal file
116
deploy/helm/planet/templates/single-node-deps.yaml
Normal 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 }}
|
||||
29
deploy/helm/planet/values.single-node.yaml
Normal file
29
deploy/helm/planet/values.single-node.yaml
Normal 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
|
||||
73
deploy/helm/planet/values.yaml
Normal file
73
deploy/helm/planet/values.yaml
Normal 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
|
||||
@@ -8,6 +8,23 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `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
|
||||
|
||||
Released: 2026-05-12
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -346,11 +346,11 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
|
||||
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` 全量返回。
|
||||
|
||||
### 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 结果。
|
||||
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`。
|
||||
@@ -462,8 +462,8 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100
|
||||
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`。
|
||||
- 同一时间窗口内多来源相近轨迹点只展示一个点。
|
||||
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
|
||||
- `/api/v1/vessels/snapshot` 只读取 AIS raw observation 聚合结果;legacy latest position 不再参与船只快照。
|
||||
- `/api/v1/visualization/geo/vessels` 返回 `410 Gone`,客户端必须迁移到新 snapshot API。
|
||||
- `/api/v1/vessels/snapshot` 优先读取 AIS raw observation 聚合结果;当当前 raw 窗口为空时,允许受控 fallback 到 legacy latest position。
|
||||
- `/api/v1/visualization/geo/vessels` 路由已移除,客户端必须迁移到新 snapshot API。
|
||||
- AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws` 的 `vessels` channel 推送增量。
|
||||
- AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。
|
||||
- `mmsi`、`imo`、`callsign` 等身份编号在前端不显示千分位符。
|
||||
|
||||
@@ -216,7 +216,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再
|
||||
|
||||
### 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. 后端排序策略
|
||||
|
||||
|
||||
@@ -120,11 +120,12 @@ CREATE UNIQUE INDEX ON vessel_latest(mmsi);
|
||||
|
||||
#### 1.3 API 端点
|
||||
|
||||
```
|
||||
GET /api/v1/visualization/geo/vessels
|
||||
?bbox=lon_min,lat_min,lon_max,lat_max # 视口裁剪
|
||||
```http
|
||||
GET /api/v1/vessels/snapshot
|
||||
?bbox=lon_min,lat_min,lon_max,lat_max # 必填,视口裁剪
|
||||
?zoom=12 # 必填,当前缩放
|
||||
?type=cargo,tanker,passenger # 船型过滤
|
||||
?limit=0 # 可选;不传或 0 表示不裁剪数量
|
||||
?limit=1000 # 默认 1000,最大 5000
|
||||
→ GeoJSON FeatureCollection(Point)
|
||||
|
||||
GET /api/v1/visualization/vessels/{mmsi} # 单船详情
|
||||
@@ -163,7 +164,7 @@ GeoJSON Feature 格式:
|
||||
- 后端 BarentsWatch collector 继续以 HTTP polling 方式采集
|
||||
- AISStream 等实时源以独立 WebSocket collector 写入原始观测层
|
||||
- 展示接口从聚合服务读取当前船只视图,而不是由单个 collector 决定最终展示值
|
||||
- 前端默认不再给 `/geo/vessels` 传 `limit=5000`,`VESSEL_CONFIG.maxRenderedMarkers = 0` 表示不做前端数量裁剪;后续如性能不足再引入显式 LOD 上限
|
||||
- 旧 `/api/v1/visualization/geo/vessels` 路由已移除,前端必须使用受控 snapshot 接口。
|
||||
- marker 颜色、详情卡、hover 和搜索结果必须共享 `vessel_type_display` 船型归一化结果,避免 AIS 数字类型码已驱动颜色但卡片仍显示 `Other`
|
||||
- 前端是否升级为 WebSocket delta push 是独立优化,不影响后端采集器可以使用 WebSocket 接上游实时源
|
||||
|
||||
|
||||
55
docs/plans/production-delivery-cicd-stabilization-plan.md
Normal file
55
docs/plans/production-delivery-cicd-stabilization-plan.md
Normal 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 暴露 Ingress;backend/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。
|
||||
@@ -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.
|
||||
|
||||
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 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
|
||||
```
|
||||
|
||||
`/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:
|
||||
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
|
||||
@@ -130,11 +130,12 @@ Steps:
|
||||
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
|
||||
4. Click the plug icon to test; confirm it reports `Reachable`
|
||||
5. Save collector settings
|
||||
6. Trigger the `aisstream_vessels` collector from the collection scheduler
|
||||
7. Watch the `AISStream Runtime` panel:
|
||||
6. Open the `Realtime Streams` tab on `/datasources` and find `AISStream Realtime Vessels`
|
||||
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
|
||||
- `messages this round` should keep growing
|
||||
- `disconnected` with `ConnectionResetError` means the upstream or network dropped; re-trigger or wait for reconnect
|
||||
- `total stored`, `last 24h`, `last 1h`, and `unique MMSI` show historical collection volume
|
||||
- `disconnected` with a recent error means the upstream or network dropped; click `Reconnect`
|
||||
|
||||
## Configure AI Credentials
|
||||
|
||||
@@ -203,7 +204,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## 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"
|
||||
- `/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
|
||||
|
||||
@@ -22,10 +22,10 @@ image_exists AND stamp_non_empty AND fingerprint_match
|
||||
|
||||
### Fix
|
||||
|
||||
The stamp file moved to a persistent cache path:
|
||||
The stamp file moved from a temporary location to a persistent cache path:
|
||||
|
||||
```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:
|
||||
@@ -92,7 +92,7 @@ COPY aiprovider /app/aiprovider
|
||||
|
||||
### 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`
|
||||
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`.
|
||||
|
||||
## 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
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
|
||||
|
||||
|
||||
@@ -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`,需要确认后端进程实际继承到了该环境变量。
|
||||
|
||||
控制台通过 `/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 观测写入后不会直接替换最终船只记录,而是先保存为 raw observation:
|
||||
@@ -345,7 +356,7 @@ GET /api/v1/visualization/vessels/{mmsi}/track
|
||||
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 推送。客户端订阅时必须带当前视口:
|
||||
|
||||
|
||||
@@ -290,7 +290,7 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
|
||||
|
||||
连接验证会读取保存配置、环境变量和 `~/.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 初始状态应调用:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
该接口只查询本地 `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 映射运行时
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ AIS 船只图层入口:
|
||||
|
||||
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`,因为这些统计保持全量/全局口径,不随当前视口变化。
|
||||
|
||||
|
||||
@@ -133,11 +133,12 @@
|
||||
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
|
||||
4. 点击插头图标进行连接测试,确认显示 `可用`
|
||||
5. 保存采集器设置
|
||||
6. 到采集调度入口运行 `aisstream_vessels` collector
|
||||
7. 在 `AISStream 运行状态` 中观察:
|
||||
- `streaming` / `connected` 表示正在接收实时流
|
||||
- `本轮消息数` 应持续增长
|
||||
- 如果显示 `disconnected` 且错误为 `ConnectionResetError`,需要重新触发或等待重连
|
||||
6. 打开 `/datasources` 的 `实时流` tab,找到 `AISStream 实时船舶`
|
||||
7. 点击 `启动`、`停止` 或 `重连` 管理长连接;这里不显示百分比进度
|
||||
8. 在实时流卡片中观察:
|
||||
- `connected` 表示正在接收实时流
|
||||
- `累计入库`、`近 24h`、`近 1h` 和 `唯一 MMSI` 用于判断历史采集量
|
||||
- 如果显示 `disconnected` 且有最近错误,可以点击 `重连`
|
||||
|
||||
## 配置 AI 凭证
|
||||
|
||||
@@ -206,7 +207,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
## 数据探索
|
||||
|
||||
- `/datasources`:数据源目录。可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“采集当前筛选”只触发当前筛选范围。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/datasources`:数据源目录。`采集任务` tab 面向一次性/定时采集器,可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“一键采集”触发当前筛选范围。`实时流` tab 面向 AISStream / WebSocket 长连接,展示连接健康、累计入库、时间窗统计和启动 / 停止 / 重连操作。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置;接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
|
||||
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
|
||||
- `/bgp`:BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
|
||||
- `/alerts/system`、`/alerts/bgp`、`/alerts/situational`:系统、BGP、态势告警
|
||||
|
||||
@@ -22,10 +22,10 @@ image_exists AND stamp_non_empty AND fingerprint_match
|
||||
|
||||
### 修复
|
||||
|
||||
将戳文件路径从 `/tmp/` 改到持久路径:
|
||||
将戳文件路径从临时目录改到持久缓存路径:
|
||||
|
||||
```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`
|
||||
2. `~/.zshrc` 中简单的 `export AI_...=...` 或 `AI_...=...` 行
|
||||
@@ -202,6 +202,62 @@ PY
|
||||
|
||||
修复戳文件路径后,无参 `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 可选启动
|
||||
|
||||
`planet.sh` 现在可以管理本地动作捕捉 Agent,但默认不会启动它,避免普通开发机因为没有摄像头、OpenCV 或 MediaPipe 而影响后端/前端启动。
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
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 工具可选
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 采集任务`,AISStream / WebSocket 长连接看 `/datasources -> 实时流` 的健康状态和计数
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.52.0`
|
||||
- `dev` 当前开发分支历史推导到:`0.53.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| 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.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 |
|
||||
| `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 |
|
||||
|
||||
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
*.log
|
||||
coverage
|
||||
.DS_Store
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1-alpine
|
||||
FROM oven/bun:1-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,7 +6,16 @@ COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
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
|
||||
|
||||
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
56
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.52.0",
|
||||
"version": "0.53.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -259,9 +259,10 @@ const vesselIconLayer = createInteractableLayer({
|
||||
});
|
||||
|
||||
const DEFAULT_VESSEL_VIEWPORT = {
|
||||
bbox: [-180, -90, 180, 90],
|
||||
zoom: 2,
|
||||
bbox: [-10, 50, 35, 75],
|
||||
zoom: 4,
|
||||
};
|
||||
const MAX_VESSEL_SUBSCRIPTION_BBOX_AREA = 2500;
|
||||
|
||||
export function getVesselMarkers() {
|
||||
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;
|
||||
const subscriptionOptions = normalizeVesselViewportOptions({ bbox, zoom, limit });
|
||||
const connect = () => {
|
||||
if (!showVessels || vesselStreamSocket) return;
|
||||
const socket = new WebSocket(getVesselStreamUrl());
|
||||
@@ -369,9 +396,9 @@ export function startVesselRealtime(earth, { onUpdate } = {}) {
|
||||
type: "subscribe",
|
||||
data: {
|
||||
channel: "vessels",
|
||||
bbox: DEFAULT_VESSEL_VIEWPORT.bbox,
|
||||
zoom: DEFAULT_VESSEL_VIEWPORT.zoom,
|
||||
limit: VESSEL_CONFIG.maxRenderedMarkers,
|
||||
bbox: subscriptionOptions.bbox,
|
||||
zoom: subscriptionOptions.zoom,
|
||||
limit: subscriptionOptions.limit,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
@@ -84,6 +85,50 @@ interface CustomDataSourceOverride {
|
||||
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 {
|
||||
id: number
|
||||
name: string
|
||||
@@ -200,6 +245,10 @@ const PRODUCT_TAG_COLORS: Record<string, string> = {
|
||||
other: 'default',
|
||||
}
|
||||
|
||||
function isRealtimeBuiltinSource(source: BuiltInDataSource | UnifiedDataSource) {
|
||||
return source.source === 'aisstream_vessels'
|
||||
}
|
||||
|
||||
function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
|
||||
return {
|
||||
key: `builtin:${source.id}`,
|
||||
@@ -242,7 +291,11 @@ function DataSources() {
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
||||
const [customOverrides, setCustomOverrides] = useState<CustomDataSourceOverride[]>([])
|
||||
const [realtimeSources, setRealtimeSources] = useState<RealtimeSource[]>([])
|
||||
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 [forceTriggerAll, setForceTriggerAll] = useState(false)
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
|
||||
@@ -259,7 +312,11 @@ function DataSources() {
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
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(
|
||||
() => selectedRowKeys
|
||||
.map((key) => allSources.find((source) => source.key === key)?.id)
|
||||
@@ -267,13 +324,13 @@ function DataSources() {
|
||||
[allSources, selectedRowKeys],
|
||||
)
|
||||
|
||||
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
|
||||
const collectedBuiltInCount = builtInSources.filter((source) => source.has_collected_data).length
|
||||
const runningBuiltInSources = builtInSources.filter((source) => source.is_running)
|
||||
const activeBuiltInCount = taskBuiltInSources.filter((source) => source.is_active).length
|
||||
const collectedBuiltInCount = taskBuiltInSources.filter((source) => source.has_collected_data).length
|
||||
const runningBuiltInSources = taskBuiltInSources.filter((source) => source.is_running)
|
||||
const runningBuiltInCount = runningBuiltInSources.length
|
||||
const aggregateProgress = runningBuiltInCount > 0
|
||||
? Math.round(
|
||||
builtInSources
|
||||
taskBuiltInSources
|
||||
.filter((source) => source.is_running)
|
||||
.reduce((sum, source) => sum + (source.progress || 0), 0) / runningBuiltInCount,
|
||||
)
|
||||
@@ -304,10 +361,27 @@ function DataSources() {
|
||||
}
|
||||
}, [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(() => {
|
||||
void fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchRealtimeSources()
|
||||
}, [fetchRealtimeSources])
|
||||
|
||||
useEffect(() => {
|
||||
const visibleKeys = new Set(allSources.map((source) => source.key))
|
||||
setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key))))
|
||||
@@ -324,6 +398,20 @@ function DataSources() {
|
||||
return () => observer.disconnect()
|
||||
}, [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 res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`)
|
||||
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 = [
|
||||
{
|
||||
title: '名称',
|
||||
@@ -591,8 +833,16 @@ function DataSources() {
|
||||
<div className="page-shell__header">
|
||||
<h2 style={{ margin: 0 }}>数据源</h2>
|
||||
</div>
|
||||
<div className="page-shell__body data-source-builtin-tab">
|
||||
<div className="data-source-bulk-toolbar">
|
||||
<Tabs
|
||||
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__title">采集实时进度</div>
|
||||
<div className="data-source-bulk-toolbar__progress">
|
||||
@@ -609,7 +859,7 @@ function DataSources() {
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">内置</span>
|
||||
<strong>{builtInSources.length}</strong>
|
||||
<strong>{taskBuiltInSources.length}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">已启用</span>
|
||||
@@ -646,10 +896,10 @@ function DataSources() {
|
||||
<InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} />
|
||||
</Tooltip>
|
||||
<Checkbox checked={forceTriggerAll} onChange={(event) => setForceTriggerAll(event.target.checked)}>
|
||||
强制全部采集
|
||||
强制重新采集
|
||||
</Checkbox>
|
||||
<Button type="primary" size="middle" icon={<SyncOutlined />} loading={triggerAllLoading} onClick={handleTriggerAll}>
|
||||
{selectedSourceIds.length ? '采集选中项' : '采集当前筛选'}
|
||||
{selectedSourceIds.length ? '采集选中项' : '一键采集'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
@@ -741,7 +991,65 @@ function DataSources() {
|
||||
/>
|
||||
<ScrollbarOverlay containerRef={tableRegionRef} targetSelector=".ant-table-body" />
|
||||
</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>
|
||||
|
||||
<Modal
|
||||
|
||||
440
planet.sh
440
planet.sh
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_PATH="${(%):-%N}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||
@@ -81,10 +82,12 @@ VERBOSE=0
|
||||
WAIT_VERBOSE_LOG_FILE=""
|
||||
WAIT_VERBOSE_LINE_COUNT="${WAIT_VERBOSE_LINE_COUNT:-5}"
|
||||
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_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}"
|
||||
DEPENDENCY_INSTALL_MAX_RETRIES="${DEPENDENCY_INSTALL_MAX_RETRIES:-3}"
|
||||
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}"
|
||||
DATABASE_START_MAX_RETRIES="${DATABASE_START_MAX_RETRIES:-3}"
|
||||
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_HEALTH_CHECK_ATTEMPTS="${FRONTEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
||||
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}"
|
||||
FRONTEND_RUNTIME_BIN="${FRONTEND_RUNTIME_BIN:-}"
|
||||
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"
|
||||
MOTION_AGENT_PID_FILE="/tmp/planet_motion_agent.pid"
|
||||
MOTION_AGENT_LOG_FILE="/tmp/planet_motion_agent.log"
|
||||
AI_PROVIDER_BUILD_STAMP_FILE="$HOME/.cache/planet/aiprovider_build.sha256"
|
||||
AI_PROVIDER_BUILD_LOG_FILE="/tmp/planet_aiprovider_build.log"
|
||||
MOTION_AGENT_PID_FILE="$PLANET_STATE_DIR/motion_agent.pid"
|
||||
MOTION_AGENT_LOG_FILE="$PLANET_STATE_DIR/motion_agent.log"
|
||||
AI_PROVIDER_BUILD_STAMP_FILE="$PLANET_CACHE_DIR/aiprovider_build.sha256"
|
||||
AI_PROVIDER_BUILD_LOG_FILE="$PLANET_STATE_DIR/aiprovider_build.log"
|
||||
AI_PROVIDER_IMAGE_NAME="${AI_PROVIDER_IMAGE_NAME:-planet_aiprovider:latest}"
|
||||
AI_PROVIDER_CONTAINER_NAME="${AI_PROVIDER_CONTAINER_NAME:-planet_aiprovider}"
|
||||
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-/tmp/planet_aiprovider_runtime.env}"
|
||||
PLANET_AI_PROVIDER_RUNTIME_ENV_FILE="${PLANET_AI_PROVIDER_RUNTIME_ENV_FILE:-$PLANET_STATE_DIR/aiprovider_runtime.env}"
|
||||
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=(
|
||||
SERVICE_NAME
|
||||
SERVICE_VERSION
|
||||
@@ -133,6 +149,9 @@ AI_PROVIDER_RUNTIME_ENV_NAMES=(
|
||||
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() {
|
||||
local path_entry="$1"
|
||||
[ -n "$path_entry" ] || return 0
|
||||
@@ -152,6 +171,98 @@ ensure_local_runtime_bins_on_path() {
|
||||
ensure_local_runtime_bins_on_path
|
||||
|
||||
# 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() {
|
||||
docker compose version >/dev/null 2>&1
|
||||
}
|
||||
@@ -253,36 +364,10 @@ render_wait_spinner() {
|
||||
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
|
||||
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
|
||||
local detail="${WAIT_SPINNER_DETAIL:- }"
|
||||
local verbose_line=""
|
||||
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
|
||||
flush_wait_verbose_log
|
||||
|
||||
printf "\r${DIM}·${NC} ${CYAN}%-2s${NC} ${WHITE}%s${NC}\033[K\n${DIM} %s${NC}\033[K" "$frame" "$message" "$detail" >&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
|
||||
printf "\033[1A\r" >&2
|
||||
}
|
||||
|
||||
animate_wait_spinner() {
|
||||
@@ -305,11 +390,6 @@ animate_wait_spinner() {
|
||||
|
||||
clear_wait_spinner() {
|
||||
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
|
||||
line_count=0
|
||||
@@ -318,15 +398,8 @@ clear_wait_spinner() {
|
||||
line_count=$((line_count + 1))
|
||||
done
|
||||
|
||||
if [ "$clear_verbose_lines" -eq 1 ]; then
|
||||
line_count=0
|
||||
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
|
||||
printf "\033[1A\r" >&2
|
||||
flush_wait_verbose_log
|
||||
}
|
||||
|
||||
start_wait_session() {
|
||||
@@ -366,6 +439,8 @@ reset_wait_spinner_state() {
|
||||
WAIT_SPINNER_DETAIL=""
|
||||
WAIT_SPINNER_MESSAGE=""
|
||||
WAIT_VERBOSE_LOG_FILE=""
|
||||
WAIT_VERBOSE_LAST_LOG_FILE=""
|
||||
WAIT_VERBOSE_EMITTED_LINES=0
|
||||
}
|
||||
|
||||
sanitize_wait_detail() {
|
||||
@@ -382,6 +457,39 @@ sanitize_wait_detail() {
|
||||
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() {
|
||||
WAIT_SPINNER_DETAIL="$(sanitize_wait_detail "$1")"
|
||||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ] && [ -n "$WAIT_SPINNER_MESSAGE" ]; then
|
||||
@@ -400,7 +508,7 @@ run_command_with_spinner() {
|
||||
fi
|
||||
|
||||
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"
|
||||
"$@" > "$verbose_log_file" 2>&1 &
|
||||
else
|
||||
@@ -494,7 +602,13 @@ log_error() {
|
||||
}
|
||||
|
||||
log_note() {
|
||||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||||
clear_wait_spinner
|
||||
fi
|
||||
printf "${DIM} %s${NC}\n" "$1"
|
||||
if [ "$WAIT_SESSION_ACTIVE" -eq 1 ]; then
|
||||
resume_wait_session
|
||||
fi
|
||||
}
|
||||
|
||||
log_success() {
|
||||
@@ -704,7 +818,7 @@ remove_ai_provider_containers() {
|
||||
local container_ids
|
||||
container_ids="$(list_ai_provider_containers)"
|
||||
[ -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() {
|
||||
@@ -1086,7 +1200,7 @@ wait_for_http() {
|
||||
set_wait_detail "${wait_detail_prefix} ${url} (1/${attempts})"
|
||||
|
||||
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}已就绪"
|
||||
return 0
|
||||
fi
|
||||
@@ -1179,7 +1293,7 @@ wait_for_frontend_ready() {
|
||||
fi
|
||||
|
||||
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
|
||||
stop_wait_session
|
||||
finish_wait_spinner "前端已就绪"
|
||||
@@ -1289,6 +1403,7 @@ force_cleanup_external_port_listener() {
|
||||
local saw_no_windows_listener=0
|
||||
local saw_cleanup_failure=0
|
||||
|
||||
validate_port "$port"
|
||||
is_wsl_environment || return 1
|
||||
command -v powershell.exe >/dev/null 2>&1 || return 1
|
||||
|
||||
@@ -1378,12 +1493,12 @@ EOF
|
||||
|
||||
# Service startup helpers
|
||||
wait_for_database_health() {
|
||||
wait_for_container_health "planet_postgres" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "PostgreSQL" &&
|
||||
wait_for_container_health "planet_redis" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$DATABASE_RETRY_INTERVAL" "Redis"
|
||||
wait_for_container_health "planet_postgres" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "PostgreSQL" &&
|
||||
wait_for_container_health "planet_redis" "$DATABASE_HEALTH_CHECK_ATTEMPTS" "$DATABASE_HEALTH_CHECK_INTERVAL" "Redis"
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -1401,6 +1516,19 @@ start_postgres_service() {
|
||||
# Backend lifecycle helpers
|
||||
cleanup_backend_processes() {
|
||||
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"
|
||||
|
||||
if ! wait_for_port_release "$backend_port" 15 0.2; then
|
||||
@@ -1424,11 +1552,12 @@ start_backend_with_retry() {
|
||||
fail_unreleased_port "$backend_port"
|
||||
fi
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
: > /tmp/planet_backend.log
|
||||
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 &
|
||||
: > "$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 > "$BACKEND_LOG_FILE" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
write_pid_file "$BACKEND_PID_FILE" "$BACKEND_PID"
|
||||
if [ "$VERBOSE" -eq 1 ]; then
|
||||
WAIT_VERBOSE_LOG_FILE="/tmp/planet_backend.log"
|
||||
WAIT_VERBOSE_LOG_FILE="$BACKEND_LOG_FILE"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
if wait_for_port_release "$backend_port" 5 0.2; then
|
||||
log_warn "后端端口冲突已清理,立即重试启动"
|
||||
retry=$((retry + 1))
|
||||
continue
|
||||
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
|
||||
fi
|
||||
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
|
||||
@@ -1451,7 +1580,7 @@ start_backend_with_retry() {
|
||||
done
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1500,8 +1629,7 @@ ai_provider_service_healthy() {
|
||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||
|
||||
docker inspect "$AI_PROVIDER_CONTAINER_NAME" >/dev/null 2>&1 || return 1
|
||||
curl -s --max-time "$HTTP_CHECK_MAX_TIME" \
|
||||
"http://localhost:${ai_provider_port}/health" >/dev/null 2>&1
|
||||
http_ok "http://localhost:${ai_provider_port}/health"
|
||||
}
|
||||
|
||||
ensure_database_services_healthy() {
|
||||
@@ -1596,7 +1724,7 @@ start_backend_service() {
|
||||
ensure_uv_backend_deps
|
||||
if ! start_backend_with_retry "$backend_port"; then
|
||||
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||||
tail -10 /tmp/planet_backend.log
|
||||
tail -10 "$BACKEND_LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -1650,11 +1778,12 @@ terminate_process_group() {
|
||||
local pid="$2"
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1665,7 +1794,7 @@ terminate_backend_processes() {
|
||||
local pids=""
|
||||
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
|
||||
terminate_process_group "$signal" "$pid"
|
||||
terminate_process_tree "$signal" "$pid"
|
||||
@@ -1683,12 +1812,13 @@ terminate_process_tree() {
|
||||
local pid="$2"
|
||||
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
|
||||
|
||||
if command -v pgrep >/dev/null 2>&1; then
|
||||
while IFS= read -r child_pid; do
|
||||
[ -n "$child_pid" ] || continue
|
||||
is_pid "$child_pid" || continue
|
||||
terminate_process_tree "$signal" "$child_pid"
|
||||
done < <(pgrep -P "$pid" 2>/dev/null || true)
|
||||
fi
|
||||
@@ -1821,15 +1951,13 @@ kill_port_if_requested() {
|
||||
frontend_log_indicates_port_conflict() {
|
||||
local log_file="$1"
|
||||
|
||||
[ -f "$log_file" ] || return 1
|
||||
grep -q "Port .* is already in use" "$log_file" 2>/dev/null
|
||||
log_matches "$log_file" "Port .* is already in use"
|
||||
}
|
||||
|
||||
backend_log_indicates_port_conflict() {
|
||||
local log_file="$1"
|
||||
|
||||
[ -f "$log_file" ] || return 1
|
||||
grep -Eiq "Address already in use|Errno 98" "$log_file" 2>/dev/null
|
||||
log_matches "$log_file" "Address already in use|Errno 98"
|
||||
}
|
||||
|
||||
print_windows_port_listener_details() {
|
||||
@@ -1927,18 +2055,16 @@ cleanup_frontend_processes() {
|
||||
local tracked_pid=""
|
||||
|
||||
if [ -f "$FRONTEND_PID_FILE" ]; then
|
||||
tracked_pid="$(cat "$FRONTEND_PID_FILE" 2>/dev/null || true)"
|
||||
if [ -n "$tracked_pid" ]; then
|
||||
if tracked_pid="$(read_pid_file "$FRONTEND_PID_FILE")"; then
|
||||
terminate_process_tree TERM "$tracked_pid"
|
||||
sleep 1
|
||||
terminate_process_tree KILL "$tracked_pid"
|
||||
fi
|
||||
rm -f "$FRONTEND_PID_FILE"
|
||||
remove_pid_file "$FRONTEND_PID_FILE"
|
||||
fi
|
||||
|
||||
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}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
start_frontend_with_retry() {
|
||||
@@ -1954,25 +2080,25 @@ start_frontend_with_retry() {
|
||||
log_warn "前端端口 ${frontend_port} 预清理后仍需由 Vite 启动流程确认"
|
||||
fi
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
: > /tmp/planet_frontend.log
|
||||
: > "$FRONTEND_LOG_FILE"
|
||||
local -a frontend_args
|
||||
frontend_args=("$FRONTEND_VITE_ENTRY")
|
||||
if [ "$frontend_lan_enabled" -eq 1 ]; then
|
||||
frontend_args+=(--host 0.0.0.0)
|
||||
fi
|
||||
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=$!
|
||||
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
|
||||
fi
|
||||
|
||||
cleanup_frontend_processes "$frontend_port"
|
||||
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" "前端"
|
||||
fi
|
||||
|
||||
@@ -2007,7 +2133,8 @@ start_frontend_service() {
|
||||
if ! start_frontend_with_retry "$frontend_port" "$frontend_port_requested" "$frontend_lan_enabled"; then
|
||||
stop_wait_session
|
||||
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||
tail -10 /tmp/planet_frontend.log
|
||||
tail -10 "$FRONTEND_LOG_FILE"
|
||||
print_port_listener_details "$frontend_port"
|
||||
exit 1
|
||||
fi
|
||||
stop_wait_session
|
||||
@@ -2139,7 +2266,7 @@ cleanup_exit_containers() {
|
||||
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
||||
if [ -n "$exit_containers" ]; then
|
||||
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 "残留容器已清理"
|
||||
fi
|
||||
}
|
||||
@@ -2157,7 +2284,7 @@ stop_container_if_running() {
|
||||
}
|
||||
|
||||
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"
|
||||
log_halt "后端服务已停止"
|
||||
fi
|
||||
@@ -2168,7 +2295,7 @@ stop_ai_provider_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"
|
||||
if [ -n "${FRONTEND_PORT:-}" ] && [ "$FRONTEND_PORT" != "$DEFAULT_FRONTEND_PORT" ]; then
|
||||
cleanup_frontend_processes "$FRONTEND_PORT"
|
||||
@@ -2183,11 +2310,39 @@ stop_frontend_service() {
|
||||
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() {
|
||||
local pids=""
|
||||
|
||||
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
|
||||
printf "%s\n" "$pids"
|
||||
fi
|
||||
@@ -2206,7 +2361,7 @@ cleanup_motion_agent_processes() {
|
||||
terminate_process_tree "$signal" "$pid"
|
||||
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() {
|
||||
@@ -2298,7 +2453,7 @@ start_motion_agent_service() {
|
||||
cd "$SCRIPT_DIR"
|
||||
nohup "$SCRIPT_DIR/.venv/bin/python" "${motion_args[@]}" > "$MOTION_AGENT_LOG_FILE" 2>&1 &
|
||||
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
|
||||
log_success "Motion Agent 已就绪"
|
||||
@@ -2308,6 +2463,7 @@ start_motion_agent_service() {
|
||||
|
||||
log_error "Motion Agent 启动失败"
|
||||
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 "如需真实摄像头识别,请确认系统存在 /dev/video*;脚本会自动发现,也可用 --motion-agent-camera-indexes 1,2 覆盖。"
|
||||
log_note "WSL 下也可用 --motion-agent-camera-urls rtsp://... 或 http://... 接入手机/网络摄像头。"
|
||||
@@ -2323,6 +2479,24 @@ stop_motion_agent_service() {
|
||||
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() {
|
||||
local username
|
||||
local password
|
||||
@@ -2433,14 +2607,27 @@ start() {
|
||||
parse_service_args "$@"
|
||||
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
|
||||
|
||||
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"
|
||||
STARTED_FRONTEND_THIS_RUN=1
|
||||
if [ "$MOTION_AGENT_REQUESTED" -eq 1 ]; then
|
||||
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
|
||||
STARTED_MOTION_AGENT_THIS_RUN=1
|
||||
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_note "智能星球计划: http://localhost:${FRONTEND_PORT}/earth"
|
||||
log_note "智能星球仪表盘: http://localhost:${FRONTEND_PORT}/admin"
|
||||
@@ -2468,6 +2655,10 @@ stop() {
|
||||
restart() {
|
||||
parse_service_args "$@"
|
||||
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
|
||||
stop
|
||||
@@ -2506,6 +2697,24 @@ restart() {
|
||||
start_motion_agent_service "$MOTION_AGENT_PORT" "$MOTION_AGENT_DRY_RUN" "$FRONTEND_LAN_ENABLED"
|
||||
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 ""
|
||||
log_success "重启完成"
|
||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||
@@ -2529,34 +2738,23 @@ restart() {
|
||||
}
|
||||
|
||||
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}"
|
||||
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 -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
|
||||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
|
||||
else
|
||||
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
|
||||
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
|
||||
print_http_health_status "后端" "http://localhost:${backend_port}/health"
|
||||
print_http_health_status "AI Provider" "http://localhost:${ai_provider_port}/health"
|
||||
print_http_health_status "前端" "http://localhost:${frontend_port}"
|
||||
print_process_health_status "Motion Agent" "python.*-m motion_agent" "$MOTION_AGENT_PID_FILE"
|
||||
}
|
||||
|
||||
log() {
|
||||
@@ -2564,12 +2762,12 @@ log() {
|
||||
-f|--frontend)
|
||||
log_step "查看前端日志"
|
||||
log_note "按 Ctrl+C 退出"
|
||||
tail -f /tmp/planet_frontend.log
|
||||
tail -f "$FRONTEND_LOG_FILE"
|
||||
;;
|
||||
-b|--backend)
|
||||
log_step "查看后端日志"
|
||||
log_note "按 Ctrl+C 退出"
|
||||
tail -f /tmp/planet_backend.log
|
||||
tail -f "$BACKEND_LOG_FILE"
|
||||
;;
|
||||
-a|--ai-provider)
|
||||
log_step "查看 AI Provider 日志"
|
||||
@@ -2584,11 +2782,11 @@ log() {
|
||||
*)
|
||||
log_step "查看最近日志"
|
||||
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"
|
||||
docker logs --tail 20 planet_aiprovider 2>/dev/null || 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"
|
||||
tail -20 "$MOTION_AGENT_LOG_FILE" 2>/dev/null || log_note "无日志"
|
||||
;;
|
||||
@@ -2611,6 +2809,8 @@ parse_global_args() {
|
||||
GLOBAL_ARG_REMAINDER=("$@")
|
||||
}
|
||||
|
||||
trap cleanup_failed_start EXIT
|
||||
|
||||
parse_global_args "$@"
|
||||
set -- "${GLOBAL_ARG_REMAINDER[@]}"
|
||||
|
||||
@@ -2637,7 +2837,7 @@ case "$1" in
|
||||
;;
|
||||
*)
|
||||
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 "stop 停止服务"
|
||||
log_note "restart 重启服务,可选: -b [后端端口] -f [前端端口] -a [AI Provider 端口] -m [Motion Agent] -d --allow-lan --verbose"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.52.0"
|
||||
version = "0.53.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
Reference in New Issue
Block a user