Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
emelianov
2026-06-15 10:13:33 +03:00
95 changed files with 5608 additions and 555 deletions
+2
View File
@@ -53,6 +53,7 @@ out/
/pcp_zip.zip /pcp_zip.zip
/pcp-chatgpt-context*.zip /pcp-chatgpt-context*.zip
/deploy/local-bundle/build/ /deploy/local-bundle/build/
/deploy/local-bundle/.env
/deploy/local-bundle/pcp-local-bundle-*.tar.gz /deploy/local-bundle/pcp-local-bundle-*.tar.gz
### Node / Vite ### ### Node / Vite ###
@@ -69,3 +70,4 @@ out/
/services/pcp-tgu-ops-ui/yarn-debug.log* /services/pcp-tgu-ops-ui/yarn-debug.log*
/services/pcp-tgu-ops-ui/yarn-error.log* /services/pcp-tgu-ops-ui/yarn-error.log*
/services/pcp-tgu-ops-ui/pnpm-debug.log* /services/pcp-tgu-ops-ui/pnpm-debug.log*
.gstack/
+7
View File
@@ -20,6 +20,7 @@ workflow:
- services/pcp-tgu-service/**/* - services/pcp-tgu-service/**/*
- services/pcp-tgu-ui-service/**/* - services/pcp-tgu-ui-service/**/*
- services/pcp-dynamic-plan-service/**/* - services/pcp-dynamic-plan-service/**/*
- services/pcp-gateway-service/**/*
- libs/pcp-types-lib/**/* - libs/pcp-types-lib/**/*
- libs/ballistics-lib/**/* - libs/ballistics-lib/**/*
- build.gradle.kts - build.gradle.kts
@@ -43,6 +44,7 @@ workflow:
- helm/pcp-tgu-service/**/* - helm/pcp-tgu-service/**/*
- helm/pcp-tgu-ui-service/**/* - helm/pcp-tgu-ui-service/**/*
- helm/pcp-dynamic-plan-service/**/* - helm/pcp-dynamic-plan-service/**/*
- helm/pcp-gateway-service/**/*
- if: '$CI_PIPELINE_SOURCE == "push"' - if: '$CI_PIPELINE_SOURCE == "push"'
changes: changes:
- .gitlab-ci.yml - .gitlab-ci.yml
@@ -64,6 +66,7 @@ workflow:
- services/pcp-tgu-service/**/* - services/pcp-tgu-service/**/*
- services/pcp-tgu-ui-service/**/* - services/pcp-tgu-ui-service/**/*
- services/pcp-dynamic-plan-service/**/* - services/pcp-dynamic-plan-service/**/*
- services/pcp-gateway-service/**/*
- libs/pcp-types-lib/**/* - libs/pcp-types-lib/**/*
- libs/ballistics-lib/**/* - libs/ballistics-lib/**/*
- build.gradle.kts - build.gradle.kts
@@ -87,6 +90,7 @@ workflow:
- helm/pcp-tgu-service/**/* - helm/pcp-tgu-service/**/*
- helm/pcp-tgu-ui-service/**/* - helm/pcp-tgu-ui-service/**/*
- helm/pcp-dynamic-plan-service/**/* - helm/pcp-dynamic-plan-service/**/*
- helm/pcp-gateway-service/**/*
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master"'
changes: changes:
- services/pcp-ballistics-service/**/* - services/pcp-ballistics-service/**/*
@@ -104,6 +108,7 @@ workflow:
- services/pcp-tgu-service/**/* - services/pcp-tgu-service/**/*
- services/pcp-tgu-ui-service/**/* - services/pcp-tgu-ui-service/**/*
- services/pcp-dynamic-plan-service/**/* - services/pcp-dynamic-plan-service/**/*
- services/pcp-gateway-service/**/*
- libs/pcp-types-lib/**/* - libs/pcp-types-lib/**/*
- libs/ballistics-lib/**/* - libs/ballistics-lib/**/*
- build.gradle.kts - build.gradle.kts
@@ -127,6 +132,7 @@ workflow:
- helm/pcp-tgu-service/**/* - helm/pcp-tgu-service/**/*
- helm/pcp-tgu-ui-service/**/* - helm/pcp-tgu-ui-service/**/*
- helm/pcp-dynamic-plan-service/**/* - helm/pcp-dynamic-plan-service/**/*
- helm/pcp-gateway-service/**/*
- when: never - when: never
default: default:
@@ -168,3 +174,4 @@ include:
- local: .gitlab/ci/pcp-tgu-service.yml - local: .gitlab/ci/pcp-tgu-service.yml
- local: .gitlab/ci/pcp-tgu-ui-service.yml - local: .gitlab/ci/pcp-tgu-ui-service.yml
- local: .gitlab/ci/pcp-dynamic-plan-service.yml - local: .gitlab/ci/pcp-dynamic-plan-service.yml
- local: .gitlab/ci/pcp-gateway-service.yml
+191
View File
@@ -0,0 +1,191 @@
variables:
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
GATEWAY_SERVICE_DIR: "services/pcp-gateway-service"
GATEWAY_HELM_CHART_DIR: "helm/pcp-gateway-service"
GATEWAY_HELM_RELEASE: "pcp-gateway-service"
GATEWAY_IMAGE_REPOSITORY: "$INTERNAL_REGISTRY/pcp-gateway-service"
GATEWAY_IMAGE_TAG: "$CI_COMMIT_SHORT_SHA"
GATEWAY_K8S_NAMESPACE_DEV: "pcp-dev"
.gateway_changes: &gateway_changes
- services/pcp-gateway-service/**/*
- build.gradle.kts
- settings.gradle.kts
- .gitlab/ci/pcp-gateway-service.yml
- helm/pcp-gateway-service/**/*
- deploy/**/*
.gateway:rules:build-and-image:
rules:
- if: '$RUN_ALL == "true"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: *gateway_changes
- if: '$CI_COMMIT_BRANCH == "dev"'
changes: *gateway_changes
- if: '$CI_COMMIT_BRANCH == "master"'
changes: *gateway_changes
- when: never
.gateway:rules:test:
rules:
- if: '$RUN_ALL == "true"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes: *gateway_changes
- if: '$CI_COMMIT_BRANCH == "dev"'
changes: *gateway_changes
when: manual
- if: '$CI_COMMIT_BRANCH == "master"'
changes: *gateway_changes
- when: never
.gateway:rules:deploy-dev:
rules:
- if: '$RUN_ALL == "true"'
when: manual
- if: '$CI_COMMIT_BRANCH == "dev"'
changes: *gateway_changes
when: manual
- when: never
.gateway:gradle-cache:
cache:
key: "gradle-gateway-$CI_DEFAULT_BRANCH"
paths:
- .gradle/wrapper
- .gradle/caches
policy: pull-push
.gateway:docker-build:
image: repo.nstart.cloud/library/docker:26
services:
- name: repo.nstart.cloud/library/docker:26-dind
alias: docker
command: ["--tls=false", "--insecure-registry=registry.k8s.265.nstart.local"]
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
DOCKER_BUILDKIT: "1"
before_script:
- docker version
- docker info
.gateway:helm-deploy:
image: repo.nstart.cloud/dtzar/helm-kubectl:3.14.0
before_script:
- mkdir -p /root/.kube
- helm version
after_script:
- rm -f /root/.kube/config
gateway:test:
stage: test
image: repo.nstart.cloud/library/gradle:jdk21-ubi10
extends:
- .gateway:rules:test
- .gateway:gradle-cache
script:
- gradle --no-daemon :services:pcp-gateway-service:test
artifacts:
when: always
reports:
junit:
- services/pcp-gateway-service/build/test-results/test/*.xml
paths:
- services/pcp-gateway-service/build/reports/tests/
expire_in: 7 days
gateway:build:
stage: build
image: repo.nstart.cloud/library/gradle:jdk21-ubi10
extends:
- .gateway:rules:build-and-image
- .gateway:gradle-cache
needs:
- job: gateway:test
artifacts: true
script:
- gradle --no-daemon :services:pcp-gateway-service:bootJar
artifacts:
when: always
paths:
- services/pcp-gateway-service/build/libs/*.jar
expire_in: 7 days
gateway:package:
stage: package
extends:
- .gateway:rules:build-and-image
- .gateway:docker-build
needs:
- job: gateway:build
artifacts: true
script:
- 'echo "GATEWAY_IMAGE_REPOSITORY=$GATEWAY_IMAGE_REPOSITORY"'
- |
JAR_COUNT="$(find "$GATEWAY_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)"
if [ "$JAR_COUNT" -ne 1 ]; then
echo "Expected exactly one executable jar, found: $JAR_COUNT"
find "$GATEWAY_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' -print || true
exit 1
fi
JAR_FILE="$(find "$GATEWAY_SERVICE_DIR/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"
- rm -rf docker-context
- mkdir -p docker-context/build/libs
- cp "$GATEWAY_SERVICE_DIR/Dockerfile" docker-context/Dockerfile
- cp "$JAR_FILE" docker-context/build/libs/app.jar
- find docker-context -maxdepth 4 -type f -print -exec ls -la {} \;
- docker build --file docker-context/Dockerfile --tag "$GATEWAY_IMAGE_REPOSITORY:$GATEWAY_IMAGE_TAG" docker-context
- docker push "$GATEWAY_IMAGE_REPOSITORY:$GATEWAY_IMAGE_TAG"
- printf "GATEWAY_IMAGE=%s\n" "$GATEWAY_IMAGE_REPOSITORY:$GATEWAY_IMAGE_TAG" > gateway-image.env
artifacts:
reports:
dotenv: gateway-image.env
expire_in: 7 days
gateway:deploy:dev:
stage: deploy
extends:
- .gateway:rules:deploy-dev
- .gateway:helm-deploy
needs:
- job: gateway:package
artifacts: true
environment:
name: dev/pcp-gateway-service
script:
- |
if [ -n "${KUBE_CONFIG_DEV:-}" ]; then
printf '%s' "$KUBE_CONFIG_DEV" > /root/.kube/config
elif [ -n "${KUBE_CONFIG_DEV_B64:-}" ]; then
if printf '%s' "$KUBE_CONFIG_DEV_B64" | grep -qE '(^apiVersion:|^kind: Config|^clusters:)'; then
printf '%s' "$KUBE_CONFIG_DEV_B64" > /root/.kube/config
elif ! printf '%s' "$KUBE_CONFIG_DEV_B64" | base64 -d > /root/.kube/config; then
echo "KUBE_CONFIG_DEV_B64 is not valid base64. Set KUBE_CONFIG_DEV to raw kubeconfig or KUBE_CONFIG_DEV_B64 to base64 kubeconfig."
exit 1
fi
else
echo "KUBE_CONFIG_DEV or KUBE_CONFIG_DEV_B64 must be set."
exit 1
fi
- chmod 600 /root/.kube/config
- export KUBECONFIG=/root/.kube/config
- 'export IMAGE_REPOSITORY="${GATEWAY_IMAGE%:*}"'
- 'export IMAGE_TAG="${GATEWAY_IMAGE##*:}"'
- 'echo "Deploy image: $IMAGE_REPOSITORY:$IMAGE_TAG"'
- >
helm upgrade --install "$GATEWAY_HELM_RELEASE"
"$GATEWAY_HELM_CHART_DIR"
--namespace "$GATEWAY_K8S_NAMESPACE_DEV"
--create-namespace
--values "$GATEWAY_HELM_CHART_DIR/values.yaml"
--values "$GATEWAY_HELM_CHART_DIR/values-dev.yaml"
--set-string image.repository="$IMAGE_REPOSITORY"
--set-string image.tag="$IMAGE_TAG"
--set-string app.profile=dev
--set-string app.configLabel=dev
--atomic
--wait
--timeout 15m
+6 -6
View File
@@ -16,18 +16,18 @@ allprojects {
group = "space.nstart.pcp" group = "space.nstart.pcp"
ext.set("kotlinVersion", "2.2.20") ext.set("kotlinVersion", "2.2.20")
repositories { repositories {
maven { // maven {
url = uri("https://repo.nstart.cloud/repository/maven-proxy/") // url = uri("https://repo.nstart.cloud/repository/maven-proxy/")
} // }
mavenCentral { mavenCentral {
content { content {
includeGroup("org.jacoco") includeGroup("org.jacoco")
includeGroup("org.ow2.asm") includeGroup("org.ow2.asm")
} }
} }
// mavenCentral() mavenCentral()
// mavenLocal() mavenLocal()
// gradlePluginPortal() gradlePluginPortal()
maven { url = uri("https://jitpack.io") } maven { url = uri("https://jitpack.io") }
exclusiveContent { exclusiveContent {
+2
View File
@@ -29,6 +29,7 @@ pcp:
coverage-scheme: 8080 coverage-scheme: 8080
satellite-catalog: 8080 satellite-catalog: 8080
dynamic-plan: 8080 dynamic-plan: 8080
gateway: 8080
services: services:
config: http://${pcp.network.host}:${pcp.ports.config} config: http://${pcp.network.host}:${pcp.ports.config}
@@ -47,6 +48,7 @@ pcp:
coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme} coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme}
satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog}
dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan} dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan}
gateway: http://pcp-gateway-service:${pcp.ports.gateway}
spring: spring:
boot: boot:
@@ -27,6 +27,7 @@ pcp:
coverage-scheme: 7012 coverage-scheme: 7012
satellite-catalog: 7013 satellite-catalog: 7013
dynamic-plan: 7014 dynamic-plan: 7014
gateway: 7080
services: services:
config: http://spring-cloud-config-server:${pcp.ports.config} config: http://spring-cloud-config-server:${pcp.ports.config}
@@ -45,6 +46,7 @@ pcp:
coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme} coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme}
satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog}
dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan} dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan}
gateway: http://pcp-gateway-service:${pcp.ports.gateway}
spring: spring:
kafka: kafka:
+2
View File
@@ -31,6 +31,7 @@ pcp:
coverage-scheme: 7012 coverage-scheme: 7012
satellite-catalog: 7013 satellite-catalog: 7013
dynamic-plan: 7014 dynamic-plan: 7014
gateway: 7080
services: services:
config: http://${pcp.network.host}:${pcp.ports.config} config: http://${pcp.network.host}:${pcp.ports.config}
@@ -49,6 +50,7 @@ pcp:
coverage-scheme: http://${pcp.network.host}:${pcp.ports.coverage-scheme} coverage-scheme: http://${pcp.network.host}:${pcp.ports.coverage-scheme}
satellite-catalog: http://${pcp.network.host}:${pcp.ports.satellite-catalog} satellite-catalog: http://${pcp.network.host}:${pcp.ports.satellite-catalog}
dynamic-plan: http://${pcp.network.host}:${pcp.ports.dynamic-plan} dynamic-plan: http://${pcp.network.host}:${pcp.ports.dynamic-plan}
gateway: http://${pcp.network.host}:${pcp.ports.gateway}
spring: spring:
boot: boot:
+105
View File
@@ -0,0 +1,105 @@
spring:
boot:
admin:
client:
instance:
service-base-url: ${pcp.services.gateway}
management-base-url: ${pcp.services.gateway}
cloud:
gateway:
server:
# WebFlux-вариант Spring Cloud Gateway (Boot 4 / Spring Cloud 2025.1).
webflux:
# CORS теперь живёт ТОЛЬКО здесь — единая точка на edge. Раньше дублировался
# per-service (pcp-ui-service/WebConfig.kt), но после сворачивания фронта на
# gateway браузер ходит к бэкендам строго через него. Страховка от cross-origin
# к самому gateway (dev напрямую, LAN-адрес рабочей станции): allowedOriginPatterns
# "*" совместим с allowCredentials=true (в отличие от allowedOrigins "*").
globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns: "*"
allowedMethods: "*"
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600
routes:
# --- Доменные сервисы. Префикс /api/pcp-<x> СРЕЗАЕТСЯ (StripPrefix=2 убирает
# 2 сегмента: api + pcp-<x>), т.е. /api/pcp-tgu/api/plans -> бэкенд /api/plans.
# Это ровно семантика прежнего vite-rewrite. Цели берём из реестра pcp.services.*.
- id: tgu
uri: ${pcp.services.tgu}
predicates:
- Path=/api/pcp-tgu/**
filters:
- StripPrefix=2
- id: ballistics
uri: ${pcp.services.ballistics}
predicates:
- Path=/api/pcp-ballistics/**
filters:
- StripPrefix=2
- id: mission
uri: ${pcp.services.mission}
predicates:
- Path=/api/pcp-mission/**
filters:
- StripPrefix=2
- id: request
uri: ${pcp.services.request}
predicates:
- Path=/api/pcp-request/**
filters:
- StripPrefix=2
- id: complex
uri: ${pcp.services.complex-mission-service}
predicates:
- Path=/api/pcp-complex/**
filters:
- StripPrefix=2
- id: slots
uri: ${pcp.services.slots}
predicates:
- Path=/api/pcp-slots/**
filters:
- StripPrefix=2
# --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix.
- id: stations
uri: ${pcp.services.stations}
predicates:
- Path=/api/stations/**
# Catch-all /api/** -> pcp-ui-service СНЯТ: фронт переведён на источники напрямую
# (tgu/баллистика/mission), ui-service выводится из эксплуатации. См.
# docs/план-перевода-с-pcp-ui-service.md и задачу #40 бэклога. Любой не-матчнутый
# /api/** теперь честно отдаёт 404 на gateway, а не молча уходит в умирающий сервис.
# Валидация JWT на edge. По умолчанию ВЫКЛЮЧЕНА (anonymous, как сейчас работает фронт).
# Для закрытого контура: GATEWAY_JWT_ENABLED=true И задать issuer через env
# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=<realm new-start>.
# Issuer намеренно НЕ объявлен здесь: пустое значение заставило бы автоконфиг поднимать
# JwtDecoder и падать на старте.
gateway:
security:
jwt-enabled: ${GATEWAY_JWT_ENABLED:false}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
---
spring:
config:
activate:
on-profile: local
server:
port: ${pcp.ports.gateway:7080}
---
spring:
config:
activate:
on-profile: dev
server:
port: ${pcp.ports.gateway:8080}
Regular → Executable
-2
View File
@@ -1,4 +1,3 @@
cat > create-service-dbs.sh <<'BASH'
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@@ -124,4 +123,3 @@ done
echo echo
echo "All requested databases processed." echo "All requested databases processed."
BASH
+24 -1
View File
@@ -1,5 +1,18 @@
COMPOSE_PROJECT_NAME=pcp-local COMPOSE_PROJECT_NAME=pcp-local
USE_EXTERNAL_INFRA=false
PCP_DOCKER_NETWORK=pcp-local_default
PCP_DOCKER_NETWORK_EXTERNAL=false
# Existing allinone infra example:
# USE_EXTERNAL_INFRA=true
# PCP_DOCKER_NETWORK=allinone_camunda
# PCP_DOCKER_NETWORK_EXTERNAL=true
# EXTERNAL_POSTGRES_CONTAINER=allinone-postgres-1
# EXTERNAL_KAFKA_CONTAINER=allinone-kafka-1
# CAMUNDA_CLIENT_ENABLED=true
# CAMUNDA_GRPC_ADDRESS=http://orchestration:26500
PCP_IMAGE_TAG=local PCP_IMAGE_TAG=local
POSTGRES_IMAGE=postgis/postgis:16-3.4 POSTGRES_IMAGE=postgis/postgis:16-3.4
@@ -8,14 +21,24 @@ KAFKA_IMAGE=apache/kafka:3.7.2
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=password POSTGRES_PASSWORD=password
POSTGRES_PORT=5432 POSTGRES_PORT=5432
PCP_POSTGRES_HOST=postgres
PCP_POSTGRES_PORT=5432
EXTERNAL_POSTGRES_CONTAINER=
KAFKA_PORT=29092 KAFKA_PORT=29092
PCP_KAFKA_HOST=kafka
PCP_KAFKA_PORT=9092
EXTERNAL_KAFKA_CONTAINER=
CAMUNDA_CLIENT_ENABLED=false
CAMUNDA_GRPC_ADDRESS=http://host.docker.internal:26500
CONFIG_SERVER_PORT=38888 CONFIG_SERVER_PORT=38888
PCP_ADMIN_PORT=7000 PCP_ADMIN_PORT=7000
PCP_TLE_MONITORING_PORT=7001
PCP_COMPLEX_MISSION_PORT=7002 PCP_COMPLEX_MISSION_PORT=7002
PCP_BALLISTICS_PORT=7003 PCP_BALLISTICS_PORT=7003
PCP_ROUTE_PROCESSING_PORT=7004
PCP_REQUEST_PORT=7005 PCP_REQUEST_PORT=7005
PCP_SLOTS_PORT=7006 PCP_SLOTS_PORT=7006
PCP_UI_PORT=7008 PCP_UI_PORT=7008
+127 -2
View File
@@ -2,6 +2,9 @@
Локальный bundle предназначен для запуска PCP на машине с Docker без GitLab Config Repo, Kubernetes и Helm. Локальный bundle предназначен для запуска PCP на машине с Docker без GitLab Config Repo, Kubernetes и Helm.
В bundle входят backend-сервисы PCP, кроме `tle-monitoring-service`; отдельный frontend
`pcp-tgu-ops-ui` также не включается.
## Состав ## Состав
- `docker-compose.yml` - локальная схема запуска. - `docker-compose.yml` - локальная схема запуска.
@@ -15,6 +18,72 @@
- `scripts/stop.sh` - остановка compose stack. - `scripts/stop.sh` - остановка compose stack.
- `scripts/status.sh` - статус контейнеров. - `scripts/status.sh` - статус контейнеров.
## Локальный запуск без архива
Для запуска на той же машине, где собираются images, архив и `docker save` не нужны.
Используйте один скрипт:
```bash
./deploy/local-bundle/run-local.sh start
```
Скрипт:
- создаёт `.env` из `.env.example`, если его ещё нет;
- собирает `bootJar` в Gradle offline mode;
- собирает локальные Docker images `pcp/<service>:<PCP_IMAGE_TAG>`;
- запускает `docker compose`.
Для вашего allinone Postgres/Kafka/Camunda перед первым запуском настройте
`deploy/local-bundle/.env`:
```env
USE_EXTERNAL_INFRA=true
PCP_DOCKER_NETWORK=allinone_camunda
PCP_DOCKER_NETWORK_EXTERNAL=true
PCP_POSTGRES_HOST=postgres
PCP_POSTGRES_PORT=5432
PCP_KAFKA_HOST=kafka
PCP_KAFKA_PORT=9092
EXTERNAL_POSTGRES_CONTAINER=allinone-postgres-1
EXTERNAL_KAFKA_CONTAINER=allinone-kafka-1
CAMUNDA_CLIENT_ENABLED=true
CAMUNDA_GRPC_ADDRESS=http://orchestration:26500
```
Команды:
```bash
./deploy/local-bundle/run-local.sh start
./deploy/local-bundle/run-local.sh status
./deploy/local-bundle/run-local.sh logs
./deploy/local-bundle/run-local.sh stop
```
## Пересборка одного сервиса
После первого запуска всего стека можно пересобирать и перезапускать только один сервис:
```bash
./deploy/local-bundle/services/pcp-request-service.sh
```
Это выполнит:
- `bootJar` только для `pcp-request-service`;
- `docker build` только для `pcp/pcp-request-service:<PCP_IMAGE_TAG>`;
- `docker compose up -d --no-deps --force-recreate pcp-request-service`.
Доступны wrapper-скрипты в `deploy/local-bundle/services/` для каждого сервиса.
То же самое можно вызвать через общий скрипт:
```bash
./deploy/local-bundle/run-service.sh pcp-request-service
./deploy/local-bundle/run-service.sh pcp-request-service build
./deploy/local-bundle/run-service.sh pcp-request-service logs
./deploy/local-bundle/run-service.sh pcp-request-service stop
```
## Сборка архива ## Сборка архива
Из корня репозитория: Из корня репозитория:
@@ -38,6 +107,7 @@ deploy/local-bundle/build/pcp-local-bundle-<version>.tar.gz
Скрипт сборки: Скрипт сборки:
- собирает `bootJar` для разворачиваемых сервисов; - собирает `bootJar` для разворачиваемых сервисов;
- запускает Gradle в offline mode по умолчанию;
- собирает Docker images сервисов; - собирает Docker images сервисов;
- сохраняет PCP service images в один `images/pcp-services.tar`; - сохраняет PCP service images в один `images/pcp-services.tar`;
- по умолчанию не сохраняет публичные infra images, чтобы не раздувать архив; - по умолчанию не сохраняет публичные infra images, чтобы не раздувать архив;
@@ -61,6 +131,41 @@ KOTLIN_DAEMON_JVMARGS="-Xmx3g -XX:MaxMetaspaceSize=768m" \
./deploy/local-bundle/build-local-bundle.sh ./deploy/local-bundle/build-local-bundle.sh
``` ```
Gradle offline mode включён по умолчанию:
```bash
GRADLE_OFFLINE=true ./deploy/local-bundle/build-local-bundle.sh
```
Если нужно один раз прогреть локальный Gradle cache, временно отключите offline mode:
```bash
GRADLE_OFFLINE=false ./deploy/local-bundle/build-local-bundle.sh
```
Важно: `GRADLE_OFFLINE=true` отключает сетевой резолв зависимостей Gradle, но Docker всё равно
должен иметь локально доступный base image. Для local bundle сервисные Dockerfile собираются с
локальным override:
```text
LOCAL_BUNDLE_RUNTIME_IMAGE=bellsoft/liberica-openjre-alpine:21.0.5
```
Поэтому перед полностью offline Docker-сборкой подтяните или импортируйте:
```bash
docker pull bellsoft/liberica-openjre-alpine:21.0.5
```
Для полностью переносимого архива с infra images заранее подтяните Postgres/Kafka и включите их
сохранение:
```bash
docker pull postgis/postgis:16-3.4
docker pull apache/kafka:3.7.2
INCLUDE_INFRA_IMAGES=true ./deploy/local-bundle/build-local-bundle.sh
```
## Запуск на локальной машине ## Запуск на локальной машине
Распаковать архив: Распаковать архив:
@@ -72,6 +177,26 @@ cd pcp-local-bundle-<version>
Проверить `.env` и при необходимости изменить порты. Проверить `.env` и при необходимости изменить порты.
Если Postgres/Kafka/Camunda уже подняты внешним allinone compose, например в сети
`allinone_camunda`, включите external infra mode в `.env`:
```env
USE_EXTERNAL_INFRA=true
PCP_DOCKER_NETWORK=allinone_camunda
PCP_DOCKER_NETWORK_EXTERNAL=true
PCP_POSTGRES_HOST=postgres
PCP_POSTGRES_PORT=5432
PCP_KAFKA_HOST=kafka
PCP_KAFKA_PORT=9092
EXTERNAL_POSTGRES_CONTAINER=allinone-postgres-1
EXTERNAL_KAFKA_CONTAINER=allinone-kafka-1
CAMUNDA_CLIENT_ENABLED=true
CAMUNDA_GRPC_ADDRESS=http://orchestration:26500
```
В этом режиме `scripts/start.sh` не поднимает свои `postgres` и `kafka`, а сервисы PCP
подключаются к уже существующим контейнерам через общую Docker network.
Импортировать images: Импортировать images:
```bash ```bash
@@ -109,9 +234,9 @@ cd pcp-local-bundle-<version>
- Config Server: `http://localhost:38888` - Config Server: `http://localhost:38888`
- Spring Boot Admin: `http://localhost:7000` - Spring Boot Admin: `http://localhost:7000`
- TLE Monitoring: `http://localhost:7001`
- Complex Mission: `http://localhost:7002` - Complex Mission: `http://localhost:7002`
- Ballistics: `http://localhost:7003` - Ballistics: `http://localhost:7003`
- Route Processing: `http://localhost:7004`
- Request Service: `http://localhost:7005` - Request Service: `http://localhost:7005`
- Slots Service: `http://localhost:7006` - Slots Service: `http://localhost:7006`
- UI: `http://localhost:7008` - UI: `http://localhost:7008`
@@ -178,13 +303,13 @@ SPRING_CLOUD_CONFIG_LABEL=
- `pcp_ballistics` - `pcp_ballistics`
- `pcp_requests` - `pcp_requests`
- `pcp_route_processing`
- `pcp_missions` - `pcp_missions`
- `pcp_slots` - `pcp_slots`
- `pcp_complex_plan` - `pcp_complex_plan`
- `pcp_satellite_catalog` - `pcp_satellite_catalog`
- `pcp_stations` - `pcp_stations`
- `pcp_tgu` - `pcp_tgu`
- `pcp_tle`
- `pcp_satellites` - `pcp_satellites`
Таблицы, схемы и seed data вручную не создаются. Схемы создаются существующими Flyway migrations внутри сервисов при старте приложений. Таблицы, схемы и seed data вручную не создаются. Схемы создаются существующими Flyway migrations внутри сервисов при старте приложений.
+15 -3
View File
@@ -26,6 +26,7 @@ service_names=(
pcp-satellite-catalog-service pcp-satellite-catalog-service
pcp-ballistics-service pcp-ballistics-service
pcp-request-service pcp-request-service
pcp-route-processing-service
slots-service slots-service
pcp-complex-mission-service pcp-complex-mission-service
pcp-mission-planing-service pcp-mission-planing-service
@@ -33,13 +34,14 @@ service_names=(
pcp-dynamic-plan-service pcp-dynamic-plan-service
pcp-stations-service pcp-stations-service
pcp-ui-service pcp-ui-service
tle-monitoring-service
pcp-tgu-service pcp-tgu-service
) )
gradle_max_workers="${GRADLE_MAX_WORKERS:-1}" gradle_max_workers="${GRADLE_MAX_WORKERS:-1}"
gradle_jvmargs="${GRADLE_JVMARGS:--Xmx2g -Dfile.encoding=UTF-8}" gradle_jvmargs="${GRADLE_JVMARGS:--Xmx2g -Dfile.encoding=UTF-8}"
kotlin_daemon_jvmargs="${KOTLIN_DAEMON_JVMARGS:--Xmx3g -XX:MaxMetaspaceSize=768m}" kotlin_daemon_jvmargs="${KOTLIN_DAEMON_JVMARGS:--Xmx3g -XX:MaxMetaspaceSize=768m}"
gradle_offline="${GRADLE_OFFLINE:-true}"
runtime_image="${LOCAL_BUNDLE_RUNTIME_IMAGE:-bellsoft/liberica-openjre-alpine:21.0.5}"
include_infra_images="${INCLUDE_INFRA_IMAGES:-false}" include_infra_images="${INCLUDE_INFRA_IMAGES:-false}"
gradle_common_args=( gradle_common_args=(
@@ -49,6 +51,10 @@ gradle_common_args=(
"-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs" "-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs"
) )
if [[ "$gradle_offline" == "true" ]]; then
gradle_common_args+=("--offline")
fi
infra_images=( infra_images=(
"${POSTGRES_IMAGE:-postgis/postgis:16-3.4}" "${POSTGRES_IMAGE:-postgis/postgis:16-3.4}"
"${KAFKA_IMAGE:-apache/kafka:3.7.2}" "${KAFKA_IMAGE:-apache/kafka:3.7.2}"
@@ -117,9 +123,11 @@ echo "Building bootJar artifacts..."
echo "Gradle max workers: $gradle_max_workers" echo "Gradle max workers: $gradle_max_workers"
echo "Gradle JVM args: $gradle_jvmargs" echo "Gradle JVM args: $gradle_jvmargs"
echo "Kotlin daemon JVM args: $kotlin_daemon_jvmargs" echo "Kotlin daemon JVM args: $kotlin_daemon_jvmargs"
echo "Gradle offline mode: $gradle_offline"
echo "Local bundle runtime image: $runtime_image"
for service_name in "${service_names[@]}"; do for service_name in "${service_names[@]}"; do
echo "Building bootJar for $service_name..." echo "Building bootJar for $service_name..."
"$repo_root/gradlew" "${gradle_common_args[@]}" ":services:$service_name:bootJar" "$repo_root/gradlew" --project-dir "$repo_root" "${gradle_common_args[@]}" ":services:$service_name:bootJar"
done done
rm -rf "$dist_dir" "$docker_contexts_dir" "$archive_path" rm -rf "$dist_dir" "$docker_contexts_dir" "$archive_path"
@@ -132,7 +140,11 @@ for service_name in "${service_names[@]}"; do
context_dir="$docker_contexts_dir/$service_name" context_dir="$docker_contexts_dir/$service_name"
copy_single_boot_jar "$service_name" "$context_dir" copy_single_boot_jar "$service_name" "$context_dir"
docker build --file "$context_dir/Dockerfile" --tag "$image_name" "$context_dir" docker build \
--build-arg "RUNTIME_IMAGE=$runtime_image" \
--file "$context_dir/Dockerfile" \
--tag "$image_name" \
"$context_dir"
service_images+=("$image_name") service_images+=("$image_name")
done done
+31 -13
View File
@@ -8,9 +8,17 @@ x-pcp-service-env: &pcp-service-env
SPRING_CLOUD_CONFIG_LABEL: "" SPRING_CLOUD_CONFIG_LABEL: ""
SPRING_KAFKA_ADMIN_AUTO_CREATE: "true" SPRING_KAFKA_ADMIN_AUTO_CREATE: "true"
SPRING_KAFKA_LISTENER_AUTO_STARTUP: "true" SPRING_KAFKA_LISTENER_AUTO_STARTUP: "true"
PCP_POSTGRES_HOST: ${PCP_POSTGRES_HOST:-postgres}
PCP_POSTGRES_PORT: ${PCP_POSTGRES_PORT:-5432}
PCP_KAFKA_HOST: ${PCP_KAFKA_HOST:-kafka}
PCP_KAFKA_PORT: ${PCP_KAFKA_PORT:-9092}
CAMUNDA_CLIENT_ENABLED: ${CAMUNDA_CLIENT_ENABLED:-false}
CAMUNDA_GRPC_ADDRESS: ${CAMUNDA_GRPC_ADDRESS:-http://host.docker.internal:26500}
x-pcp-service-common: &pcp-service-common x-pcp-service-common: &pcp-service-common
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
spring-cloud-config-server: spring-cloud-config-server:
condition: service_started condition: service_started
@@ -64,11 +72,8 @@ services:
spring-cloud-config-server: spring-cloud-config-server:
image: pcp/spring-cloud-config-server:${PCP_IMAGE_TAG:-local} image: pcp/spring-cloud-config-server:${PCP_IMAGE_TAG:-local}
restart: unless-stopped restart: unless-stopped
depends_on: extra_hosts:
postgres: - "host.docker.internal:host-gateway"
condition: service_healthy
kafka:
condition: service_started
environment: environment:
SPRING_PROFILES_ACTIVE: local SPRING_PROFILES_ACTIVE: local
CONFIG_NATIVE_SEARCH_LOCATIONS: file:/config-repo CONFIG_NATIVE_SEARCH_LOCATIONS: file:/config-repo
@@ -86,14 +91,6 @@ services:
ports: ports:
- "${PCP_ADMIN_PORT:-7000}:7000" - "${PCP_ADMIN_PORT:-7000}:7000"
tle-monitoring-service:
<<: *pcp-service-common
image: pcp/tle-monitoring-service:${PCP_IMAGE_TAG:-local}
environment:
<<: *pcp-service-env
ports:
- "${PCP_TLE_MONITORING_PORT:-7001}:7001"
pcp-complex-mission-service: pcp-complex-mission-service:
<<: *pcp-service-common <<: *pcp-service-common
image: pcp/pcp-complex-mission-service:${PCP_IMAGE_TAG:-local} image: pcp/pcp-complex-mission-service:${PCP_IMAGE_TAG:-local}
@@ -118,6 +115,14 @@ services:
ports: ports:
- "${PCP_REQUEST_PORT:-7005}:7005" - "${PCP_REQUEST_PORT:-7005}:7005"
pcp-route-processing-service:
<<: *pcp-service-common
image: pcp/pcp-route-processing-service:${PCP_IMAGE_TAG:-local}
environment:
<<: *pcp-service-env
ports:
- "${PCP_ROUTE_PROCESSING_PORT:-7004}:7004"
slots-service: slots-service:
<<: *pcp-service-common <<: *pcp-service-common
image: pcp/slots-service:${PCP_IMAGE_TAG:-local} image: pcp/slots-service:${PCP_IMAGE_TAG:-local}
@@ -182,6 +187,19 @@ services:
ports: ports:
- "${PCP_DYNAMIC_PLAN_PORT:-7014}:7014" - "${PCP_DYNAMIC_PLAN_PORT:-7014}:7014"
pcp-gateway-service:
<<: *pcp-service-common
image: pcp/pcp-gateway-service:${PCP_IMAGE_TAG:-local}
environment:
<<: *pcp-service-env
ports:
- "${PCP_GATEWAY_PORT:-7080}:7080"
volumes: volumes:
postgres-data: postgres-data:
kafka-data: kafka-data:
networks:
default:
name: ${PCP_DOCKER_NETWORK:-pcp-local_default}
external: ${PCP_DOCKER_NETWORK_EXTERNAL:-false}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
env_file="$script_dir/.env"
env_example_file="$script_dir/.env.example"
docker_contexts_dir="$script_dir/build/local-docker-contexts"
service_names=(
spring-cloud-config-server
pcp-srpring-boot-admin-server
pcp-satellite-catalog-service
pcp-ballistics-service
pcp-request-service
pcp-route-processing-service
slots-service
pcp-complex-mission-service
pcp-mission-planing-service
pcp-coverage-scheme-service
pcp-dynamic-plan-service
pcp-stations-service
pcp-ui-service
pcp-tgu-service
)
ensure_env_file() {
if [[ ! -f "$env_file" && -f "$env_example_file" ]]; then
cp "$env_example_file" "$env_file"
echo "Created $env_file from .env.example"
fi
}
ensure_source_config_repo_dir() {
if grep -q '^CONFIG_REPO_DIR=' "$env_file"; then
sed -i "s#^CONFIG_REPO_DIR=.*#CONFIG_REPO_DIR=$repo_root/config-repo#" "$env_file"
else
printf '\nCONFIG_REPO_DIR=%s/config-repo\n' "$repo_root" >> "$env_file"
fi
}
load_env() {
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name is required." >&2
exit 1
fi
}
compose() {
if docker compose version >/dev/null 2>&1; then
docker compose --project-directory "$script_dir" -f "$script_dir/docker-compose.yml" "$@"
elif command -v docker-compose >/dev/null 2>&1; then
docker-compose --project-directory "$script_dir" -f "$script_dir/docker-compose.yml" "$@"
else
echo "docker compose or docker-compose is required." >&2
exit 1
fi
}
copy_single_boot_jar() {
local service_name="$1"
local context_dir="$2"
local jar_count
local jar_file
jar_count="$(
find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l
)"
if [[ "$jar_count" != "1" ]]; then
echo "Expected exactly one bootJar for $service_name, found $jar_count:" >&2
find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' -print >&2 || true
exit 1
fi
jar_file="$(
find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1
)"
mkdir -p "$context_dir/build/libs"
cp "$repo_root/services/$service_name/Dockerfile" "$context_dir/Dockerfile"
cp "$jar_file" "$context_dir/build/libs/app.jar"
}
build_images() {
local gradle_max_workers="${GRADLE_MAX_WORKERS:-1}"
local gradle_jvmargs="${GRADLE_JVMARGS:--Xmx2g -Dfile.encoding=UTF-8}"
local kotlin_daemon_jvmargs="${KOTLIN_DAEMON_JVMARGS:--Xmx3g -XX:MaxMetaspaceSize=768m}"
local gradle_offline="${GRADLE_OFFLINE:-true}"
local runtime_image="${LOCAL_BUNDLE_RUNTIME_IMAGE:-bellsoft/liberica-openjre-alpine:21.0.5}"
local image_tag="${PCP_IMAGE_TAG:-local}"
local service_name
local image_name
local context_dir
local gradle_common_args=(
"--no-parallel"
"--max-workers=$gradle_max_workers"
"-Dorg.gradle.jvmargs=$gradle_jvmargs"
"-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs"
)
if [[ "$gradle_offline" == "true" ]]; then
gradle_common_args+=("--offline")
fi
echo "Building local Docker images..."
echo "Gradle offline mode: $gradle_offline"
echo "Local image tag: $image_tag"
echo "Runtime image: $runtime_image"
for service_name in "${service_names[@]}"; do
echo "Building bootJar for $service_name..."
"$repo_root/gradlew" --project-dir "$repo_root" "${gradle_common_args[@]}" ":services:$service_name:bootJar"
done
rm -rf "$docker_contexts_dir"
mkdir -p "$docker_contexts_dir"
for service_name in "${service_names[@]}"; do
image_name="pcp/$service_name:$image_tag"
context_dir="$docker_contexts_dir/$service_name"
copy_single_boot_jar "$service_name" "$context_dir"
echo "Building Docker image $image_name..."
docker build \
--build-arg "RUNTIME_IMAGE=$runtime_image" \
--file "$context_dir/Dockerfile" \
--tag "$image_name" \
"$context_dir"
done
}
usage() {
cat <<'EOF'
Usage:
./deploy/local-bundle/run-local.sh [command]
Commands:
start Build service images locally and start docker compose.
restart Stop compose stack, then build and start.
build Build service images locally, without starting compose.
stop Stop compose stack.
status Show compose status.
logs Follow compose logs.
EOF
}
start_local() {
build_images
"$script_dir/scripts/start.sh"
}
command="${1:-start}"
case "$command" in
help|-h|--help)
usage
exit 0
;;
esac
ensure_env_file
ensure_source_config_repo_dir
load_env
require_command docker
case "$command" in
start)
start_local
;;
restart)
"$script_dir/scripts/stop.sh"
start_local
;;
build)
build_images
;;
stop)
"$script_dir/scripts/stop.sh"
;;
status)
"$script_dir/scripts/status.sh"
;;
logs)
compose logs -f
;;
*)
usage >&2
exit 1
;;
esac
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
env_file="$script_dir/.env"
env_example_file="$script_dir/.env.example"
docker_contexts_dir="$script_dir/build/local-docker-contexts"
service_names=(
spring-cloud-config-server
pcp-srpring-boot-admin-server
pcp-satellite-catalog-service
pcp-ballistics-service
pcp-request-service
pcp-route-processing-service
slots-service
pcp-complex-mission-service
pcp-mission-planing-service
pcp-coverage-scheme-service
pcp-dynamic-plan-service
pcp-stations-service
pcp-ui-service
pcp-tgu-service
)
usage() {
cat <<'EOF'
Usage:
./deploy/local-bundle/run-service.sh SERVICE [command]
Commands:
restart Build SERVICE bootJar, rebuild its Docker image and recreate only this compose service.
build Build SERVICE bootJar and Docker image, without restarting container.
stop Stop SERVICE container.
logs Follow SERVICE logs.
Examples:
./deploy/local-bundle/run-service.sh pcp-request-service
./deploy/local-bundle/run-service.sh pcp-request-service build
EOF
}
ensure_env_file() {
if [[ ! -f "$env_file" && -f "$env_example_file" ]]; then
cp "$env_example_file" "$env_file"
echo "Created $env_file from .env.example"
fi
}
ensure_source_config_repo_dir() {
if grep -q '^CONFIG_REPO_DIR=' "$env_file"; then
sed -i "s#^CONFIG_REPO_DIR=.*#CONFIG_REPO_DIR=$repo_root/config-repo#" "$env_file"
else
printf '\nCONFIG_REPO_DIR=%s/config-repo\n' "$repo_root" >> "$env_file"
fi
}
load_env() {
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name is required." >&2
exit 1
fi
}
compose() {
if docker compose version >/dev/null 2>&1; then
docker compose --project-directory "$script_dir" -f "$script_dir/docker-compose.yml" "$@"
elif command -v docker-compose >/dev/null 2>&1; then
docker-compose --project-directory "$script_dir" -f "$script_dir/docker-compose.yml" "$@"
else
echo "docker compose or docker-compose is required." >&2
exit 1
fi
}
is_known_service() {
local service_name="$1"
local known_service_name
for known_service_name in "${service_names[@]}"; do
if [[ "$known_service_name" == "$service_name" ]]; then
return 0
fi
done
return 1
}
copy_single_boot_jar() {
local service_name="$1"
local context_dir="$2"
local jar_count
local jar_file
jar_count="$(
find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l
)"
if [[ "$jar_count" != "1" ]]; then
echo "Expected exactly one bootJar for $service_name, found $jar_count:" >&2
find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' -print >&2 || true
exit 1
fi
jar_file="$(
find "$repo_root/services/$service_name/build/libs" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1
)"
mkdir -p "$context_dir/build/libs"
cp "$repo_root/services/$service_name/Dockerfile" "$context_dir/Dockerfile"
cp "$jar_file" "$context_dir/build/libs/app.jar"
}
build_service_image() {
local service_name="$1"
local gradle_max_workers="${GRADLE_MAX_WORKERS:-1}"
local gradle_jvmargs="${GRADLE_JVMARGS:--Xmx2g -Dfile.encoding=UTF-8}"
local kotlin_daemon_jvmargs="${KOTLIN_DAEMON_JVMARGS:--Xmx3g -XX:MaxMetaspaceSize=768m}"
local gradle_offline="${GRADLE_OFFLINE:-true}"
local runtime_image="${LOCAL_BUNDLE_RUNTIME_IMAGE:-bellsoft/liberica-openjre-alpine:21.0.5}"
local image_tag="${PCP_IMAGE_TAG:-local}"
local image_name="pcp/$service_name:$image_tag"
local context_dir="$docker_contexts_dir/$service_name"
local gradle_common_args=(
"--no-parallel"
"--max-workers=$gradle_max_workers"
"-Dorg.gradle.jvmargs=$gradle_jvmargs"
"-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs"
)
if [[ "$gradle_offline" == "true" ]]; then
gradle_common_args+=("--offline")
fi
echo "Building bootJar for $service_name..."
"$repo_root/gradlew" --project-dir "$repo_root" "${gradle_common_args[@]}" ":services:$service_name:bootJar"
rm -rf "$context_dir"
copy_single_boot_jar "$service_name" "$context_dir"
echo "Building Docker image $image_name..."
docker build \
--build-arg "RUNTIME_IMAGE=$runtime_image" \
--file "$context_dir/Dockerfile" \
--tag "$image_name" \
"$context_dir"
}
http_get() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS "$url" >/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null "$url"
else
return 2
fi
}
wait_for_config_server() {
local attempt
local url="http://localhost:${CONFIG_SERVER_PORT:-38888}/actuator/health"
for attempt in {1..90}; do
if http_get "$url"; then
echo "spring-cloud-config-server is ready: $url"
return 0
fi
sleep 2
done
echo "spring-cloud-config-server is not ready after 180 seconds: $url" >&2
exit 1
}
restart_service() {
local service_name="$1"
build_service_image "$service_name"
if [[ "$service_name" == "spring-cloud-config-server" ]]; then
compose up -d --no-deps --force-recreate "$service_name"
wait_for_config_server
return 0
fi
compose up -d spring-cloud-config-server
wait_for_config_server
compose up -d --no-deps --force-recreate "$service_name"
}
service_name="${1:-}"
command="${2:-restart}"
if [[ -z "$service_name" || "$service_name" == "help" || "$service_name" == "-h" || "$service_name" == "--help" ]]; then
usage
exit 0
fi
if ! is_known_service "$service_name"; then
echo "Unknown service: $service_name" >&2
echo "Known services:" >&2
printf ' %s\n' "${service_names[@]}" >&2
exit 1
fi
ensure_env_file
ensure_source_config_repo_dir
load_env
require_command docker
case "$command" in
restart)
restart_service "$service_name"
;;
build)
build_service_image "$service_name"
;;
stop)
compose stop "$service_name"
;;
logs)
compose logs -f "$service_name"
;;
help|-h|--help)
usage
;;
*)
usage >&2
exit 1
;;
esac
+11 -2
View File
@@ -25,6 +25,10 @@ ensure_env_file() {
} }
ensure_env_file ensure_env_file
set -a
# shellcheck disable=SC1091
source "$bundle_dir/.env"
set +a
if [[ ! -d "$images_dir" ]]; then if [[ ! -d "$images_dir" ]]; then
echo "Images directory not found: $images_dir" >&2 echo "Images directory not found: $images_dir" >&2
@@ -49,7 +53,12 @@ if [[ -f "$images_dir/public-images.txt" ]]; then
fi fi
cd "$bundle_dir" cd "$bundle_dir"
echo "Starting postgres, kafka and spring-cloud-config-server..." if [[ "${USE_EXTERNAL_INFRA:-false}" == "true" ]]; then
compose up -d postgres kafka spring-cloud-config-server echo "Using external infrastructure. Starting spring-cloud-config-server only."
compose up -d spring-cloud-config-server
else
echo "Starting postgres, kafka and spring-cloud-config-server..."
compose up -d postgres kafka spring-cloud-config-server
fi
echo "Images imported and base services requested." echo "Images imported and base services requested."
+52 -5
View File
@@ -43,6 +43,26 @@ wait_for_postgres() {
exit 1 exit 1
} }
wait_for_external_postgres() {
local container_name="${EXTERNAL_POSTGRES_CONTAINER:-}"
local attempt
if [[ -z "$container_name" ]]; then
echo "EXTERNAL_POSTGRES_CONTAINER is required when USE_EXTERNAL_INFRA=true and SKIP_DB_INIT is not true." >&2
exit 1
fi
for attempt in {1..60}; do
if docker exec "$container_name" pg_isready -U "${POSTGRES_USER:-postgres}" -d postgres >/dev/null 2>&1; then
return 0
fi
sleep 2
done
echo "External Postgres is not ready after 120 seconds: $container_name" >&2
exit 1
}
create_database_if_missing() { create_database_if_missing() {
local db_name="$1" local db_name="$1"
local exists local exists
@@ -61,16 +81,35 @@ create_database_if_missing() {
compose exec -T postgres createdb -U "${POSTGRES_USER:-postgres}" "$db_name" compose exec -T postgres createdb -U "${POSTGRES_USER:-postgres}" "$db_name"
} }
create_external_database_if_missing() {
local db_name="$1"
local container_name="${EXTERNAL_POSTGRES_CONTAINER:-}"
local exists
exists="$(
docker exec "$container_name" psql -U "${POSTGRES_USER:-postgres}" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = '$db_name'" | tr -d '[:space:]'
)"
if [[ "$exists" == "1" ]]; then
echo "Database already exists: $db_name"
return 0
fi
echo "Creating database: $db_name"
docker exec "$container_name" createdb -U "${POSTGRES_USER:-postgres}" "$db_name"
}
databases=( databases=(
pcp_ballistics pcp_ballistics
pcp_requests pcp_requests
pcp_route_processing
pcp_missions pcp_missions
pcp_slots pcp_slots
pcp_complex_plan pcp_complex_plan
pcp_satellite_catalog pcp_satellite_catalog
pcp_stations pcp_stations
pcp_tgu pcp_tgu
pcp_tle
pcp_satellites pcp_satellites
) )
@@ -78,12 +117,20 @@ ensure_env_file
load_env load_env
cd "$bundle_dir" cd "$bundle_dir"
compose up -d postgres if [[ "${USE_EXTERNAL_INFRA:-false}" == "true" ]]; then
wait_for_postgres wait_for_external_postgres
for db_name in "${databases[@]}"; do for db_name in "${databases[@]}"; do
create_external_database_if_missing "$db_name"
done
else
compose up -d postgres
wait_for_postgres
for db_name in "${databases[@]}"; do
create_database_if_missing "$db_name" create_database_if_missing "$db_name"
done done
fi
echo "Database initialization completed. Tables are left to service Flyway migrations." echo "Database initialization completed. Tables are left to service Flyway migrations."
echo "No schema objects or seed data were created by this script." echo "No schema objects or seed data were created by this script."
+37 -5
View File
@@ -72,11 +72,34 @@ wait_for_kafka() {
exit 1 exit 1
} }
wait_for_external_kafka() {
local container_name="${EXTERNAL_KAFKA_CONTAINER:-}"
local attempt
if [[ -z "$container_name" ]]; then
echo "External Kafka container is not configured; skipping Kafka readiness check."
return 0
fi
for attempt in {1..60}; do
if docker exec "$container_name" kafka-topics --bootstrap-server "${PCP_KAFKA_HOST:-kafka}:${PCP_KAFKA_PORT:-9092}" --list >/dev/null 2>&1 ||
docker exec "$container_name" kafka-topics.sh --bootstrap-server "${PCP_KAFKA_HOST:-kafka}:${PCP_KAFKA_PORT:-9092}" --list >/dev/null 2>&1; then
echo "External Kafka is ready in container $container_name."
return 0
fi
sleep 2
done
echo "External Kafka is not ready after 120 seconds: $container_name" >&2
exit 1
}
app_services=( app_services=(
pcp-srpring-boot-admin-server pcp-srpring-boot-admin-server
pcp-satellite-catalog-service pcp-satellite-catalog-service
pcp-ballistics-service pcp-ballistics-service
pcp-request-service pcp-request-service
pcp-route-processing-service
slots-service slots-service
pcp-complex-mission-service pcp-complex-mission-service
pcp-mission-planing-service pcp-mission-planing-service
@@ -84,7 +107,6 @@ app_services=(
pcp-dynamic-plan-service pcp-dynamic-plan-service
pcp-stations-service pcp-stations-service
pcp-ui-service pcp-ui-service
tle-monitoring-service
pcp-tgu-service pcp-tgu-service
) )
@@ -93,9 +115,16 @@ load_env
cd "$bundle_dir" cd "$bundle_dir"
echo "Starting infrastructure..." if [[ "${USE_EXTERNAL_INFRA:-false}" == "true" ]]; then
compose up -d postgres kafka echo "Using external infrastructure. Skipping local postgres/kafka startup."
wait_for_kafka echo "Postgres endpoint for services: ${PCP_POSTGRES_HOST:-postgres}:${PCP_POSTGRES_PORT:-5432}"
echo "Kafka endpoint for services: ${PCP_KAFKA_HOST:-kafka}:${PCP_KAFKA_PORT:-9092}"
wait_for_external_kafka
else
echo "Starting infrastructure..."
compose up -d postgres kafka
wait_for_kafka
fi
if [[ "${SKIP_DB_INIT:-false}" != "true" ]]; then if [[ "${SKIP_DB_INIT:-false}" != "true" ]]; then
"$script_dir/init-db.sh" "$script_dir/init-db.sh"
@@ -106,7 +135,10 @@ compose up -d spring-cloud-config-server
wait_for_http "spring-cloud-config-server" "http://localhost:${CONFIG_SERVER_PORT:-38888}/actuator/health" wait_for_http "spring-cloud-config-server" "http://localhost:${CONFIG_SERVER_PORT:-38888}/actuator/health"
echo "Starting application services..." echo "Starting application services..."
compose up -d "${app_services[@]}" for service_name in "${app_services[@]}"; do
echo "Starting $service_name..."
compose up -d "$service_name"
done
echo "Application startup requested." echo "Application startup requested."
compose ps compose ps
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-ballistics-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-complex-mission-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-coverage-scheme-service "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-dynamic-plan-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-mission-planing-service "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-request-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-route-processing-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-satellite-catalog-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-srpring-boot-admin-server "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-stations-service "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-tgu-service "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" pcp-ui-service "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" slots-service "$@"
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
exec "$script_dir/run-service.sh" spring-cloud-config-server "$@"
+79
View File
@@ -0,0 +1,79 @@
# Local jar runner
Скрипт запускает backend-сервисы как Spring Boot jar'ы на локальной машине.
Spring Cloud Config Server стартует первым и читает конфигурацию из локального
`config-repo`.
## Запуск
Из корня репозитория:
```bash
./deploy/local-jar/run-services.sh start
```
Что делает `start`:
- собирает `bootJar` для `spring-cloud-config-server` и всех Spring Boot сервисов;
- поднимает локальные `postgres` и `kafka` через `deploy/local-bundle/docker-compose.yml`;
- создаёт сервисные базы данных, если их ещё нет;
- запускает `spring-cloud-config-server` на `http://localhost:38888`;
- проверяет lookup конфигурации из `config-repo`;
- запускает остальные jar'ы в заданной последовательности.
Runtime-файлы создаются в `build/local-jar-runtime`:
- `pids/` - PID процессов;
- `logs/` - stdout/stderr каждого сервиса.
## Команды
```bash
./deploy/local-jar/run-services.sh status
./deploy/local-jar/run-services.sh logs pcp-request-service
./deploy/local-jar/run-services.sh stop
./deploy/local-jar/run-services.sh restart
```
Только сборка:
```bash
./deploy/local-jar/run-services.sh build
```
Только инфраструктура:
```bash
./deploy/local-jar/run-services.sh infra-up
./deploy/local-jar/run-services.sh infra-down
```
## Настройки
По умолчанию config-server использует:
```text
SPRING_PROFILES_ACTIVE=local
CONFIG_NATIVE_SEARCH_LOCATIONS=file:<repo>/config-repo
SERVER_PORT=38888
```
Сервисы используют:
```text
SPRING_PROFILES_ACTIVE=local
SPRING_CLOUD_CONFIG_PROFILE=local
SPRING_CLOUD_CONFIG_LABEL=
CONFIG_SERVER_URI=http://localhost:38888
```
Полезные override'ы:
```bash
START_INFRA=false ./deploy/local-jar/run-services.sh start
INIT_DB=false ./deploy/local-jar/run-services.sh start
WAIT_FOR_APPS=false ./deploy/local-jar/run-services.sh start
JAVA_OPTS="-Xmx1g" ./deploy/local-jar/run-services.sh start
CONFIG_REPO_DIR=/path/to/config-repo ./deploy/local-jar/run-services.sh start
LOCAL_SERVICE_PROFILES=local,foo ./deploy/local-jar/run-services.sh start
```
+538
View File
@@ -0,0 +1,538 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
local_bundle_dir="$repo_root/deploy/local-bundle"
compose_file="$local_bundle_dir/docker-compose.yml"
local_bundle_env_file="$local_bundle_dir/.env"
if [[ -f "$local_bundle_env_file" ]]; then
set -a
# shellcheck disable=SC1090
source "$local_bundle_env_file"
set +a
fi
config_repo_dir="${CONFIG_REPO_DIR:-$repo_root/config-repo}"
runtime_dir="${LOCAL_JAR_RUNTIME_DIR:-$repo_root/build/local-jar-runtime}"
pid_dir="$runtime_dir/pids"
log_dir="$runtime_dir/logs"
if [[ "$config_repo_dir" != /* ]]; then
config_repo_dir="$repo_root/$config_repo_dir"
fi
if [[ "$runtime_dir" != /* ]]; then
runtime_dir="$repo_root/$runtime_dir"
pid_dir="$runtime_dir/pids"
log_dir="$runtime_dir/logs"
fi
config_service="spring-cloud-config-server"
app_services=(
pcp-srpring-boot-admin-server
pcp-stations-service
pcp-satellite-catalog-service
pcp-request-service
pcp-route-processing-service
pcp-ballistics-service
slots-service
pcp-complex-mission-service
pcp-mission-planing-service
pcp-coverage-scheme-service
pcp-dynamic-plan-service
tle-monitoring-service
pcp-tgu-service
pcp-ui-service
)
all_services=("$config_service" "${app_services[@]}")
databases=(
pcp_ballistics
pcp_requests
pcp_route_processing
pcp_missions
pcp_slots
pcp_complex_plan
pcp_satellite_catalog
pcp_stations
pcp_tgu
pcp_tle
pcp_satellites
)
declare -A service_ports=(
[spring-cloud-config-server]="${CONFIG_SERVER_PORT:-38888}"
[pcp-srpring-boot-admin-server]="${PCP_ADMIN_PORT:-7000}"
[tle-monitoring-service]="${PCP_TLE_MONITORING_PORT:-7001}"
[pcp-complex-mission-service]="${PCP_COMPLEX_MISSION_PORT:-7002}"
[pcp-ballistics-service]="${PCP_BALLISTICS_PORT:-7003}"
[pcp-route-processing-service]="${PCP_ROUTE_PROCESSING_PORT:-7004}"
[pcp-request-service]="${PCP_REQUEST_PORT:-7005}"
[slots-service]="${PCP_SLOTS_PORT:-7006}"
[pcp-ui-service]="${PCP_UI_PORT:-7008}"
[pcp-stations-service]="${PCP_STATIONS_PORT:-7009}"
[pcp-mission-planing-service]="${PCP_MISSION_PORT:-7010}"
[pcp-tgu-service]="${PCP_TGU_PORT:-7011}"
[pcp-coverage-scheme-service]="${PCP_COVERAGE_SCHEME_PORT:-7012}"
[pcp-satellite-catalog-service]="${PCP_SATELLITE_CATALOG_PORT:-7013}"
[pcp-dynamic-plan-service]="${PCP_DYNAMIC_PLAN_PORT:-7014}"
)
gradle_max_workers="${GRADLE_MAX_WORKERS:-1}"
gradle_jvmargs="${GRADLE_JVMARGS:--Xmx2g -Dfile.encoding=UTF-8}"
kotlin_daemon_jvmargs="${KOTLIN_DAEMON_JVMARGS:--Xmx3g -XX:MaxMetaspaceSize=768m}"
config_server_port="${service_ports[$config_service]}"
config_server_uri="${LOCAL_CONFIG_SERVER_URI:-http://localhost:$config_server_port}"
service_profiles="${LOCAL_SERVICE_PROFILES:-local}"
config_profiles="${LOCAL_CONFIG_PROFILES:-$service_profiles}"
postgres_host="${PCP_POSTGRES_HOST:-localhost}"
postgres_port="${PCP_POSTGRES_PORT:-5432}"
kafka_host="${PCP_KAFKA_HOST:-localhost}"
kafka_port="${PCP_KAFKA_PORT:-29092}"
gradle_common_args=(
"--no-parallel"
"--max-workers=$gradle_max_workers"
"-Dorg.gradle.jvmargs=$gradle_jvmargs"
"-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs"
)
usage() {
cat <<'EOF'
Usage:
./deploy/local-jar/run-services.sh [command]
Commands:
start Build bootJar artifacts and start local infra, config-server and services.
restart Stop local jar processes, then run start.
stop Stop jar processes started by this script.
status Show pid and log status for local jar processes.
build Build bootJar artifacts only.
infra-up Start local Postgres/Kafka via deploy/local-bundle/docker-compose.yml.
infra-down Stop local Postgres/Kafka containers.
logs NAME Tail log for a service.
Useful environment:
START_INFRA=false Do not start Postgres/Kafka containers during start.
INIT_DB=false Do not create local Postgres databases during start.
WAIT_FOR_APPS=false Do not wait for each app /actuator/health.
CONFIG_REPO_DIR=... Config repository used by spring-cloud-config-server.
JAVA_OPTS="-Xmx1g" Extra JVM options for every jar process.
LOCAL_SERVICE_PROFILES=local,foo
EOF
}
require_command() {
local command_name="$1"
if ! command -v "$command_name" >/dev/null 2>&1; then
echo "$command_name is required." >&2
exit 1
fi
}
compose() {
if docker compose version >/dev/null 2>&1; then
docker compose --project-directory "$local_bundle_dir" -f "$compose_file" "$@"
elif command -v docker-compose >/dev/null 2>&1; then
docker-compose --project-directory "$local_bundle_dir" -f "$compose_file" "$@"
else
echo "docker compose or docker-compose is required." >&2
exit 1
fi
}
http_get() {
local url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsS "$url" >/dev/null
elif command -v wget >/dev/null 2>&1; then
wget -q -O /dev/null "$url"
else
return 2
fi
}
wait_for_http() {
local name="$1"
local url="$2"
local attempts="${3:-90}"
local attempt
for ((attempt = 1; attempt <= attempts; attempt++)); do
if http_get "$url"; then
echo "$name is ready: $url"
return 0
fi
sleep 2
done
echo "$name is not ready after $((attempts * 2)) seconds: $url" >&2
exit 1
}
wait_for_postgres() {
local attempt
local postgres_user="${POSTGRES_USER:-postgres}"
for attempt in {1..60}; do
if compose exec -T postgres pg_isready -U "$postgres_user" -d postgres >/dev/null 2>&1; then
echo "Postgres is ready."
return 0
fi
sleep 2
done
echo "Postgres is not ready after 120 seconds." >&2
exit 1
}
wait_for_kafka() {
local attempt
for attempt in {1..60}; do
if compose exec -T kafka kafka-topics.sh --bootstrap-server kafka:9092 --list >/dev/null 2>&1; then
echo "Kafka is ready."
return 0
fi
sleep 2
done
echo "Kafka is not ready after 120 seconds." >&2
exit 1
}
create_database_if_missing() {
local db_name="$1"
local postgres_user="${POSTGRES_USER:-postgres}"
local exists
exists="$(
compose exec -T postgres psql -U "$postgres_user" -d postgres -tAc \
"SELECT 1 FROM pg_database WHERE datname = '$db_name'" | tr -d '[:space:]'
)"
if [[ "$exists" == "1" ]]; then
echo "Database already exists: $db_name"
return 0
fi
echo "Creating database: $db_name"
compose exec -T postgres createdb -U "$postgres_user" "$db_name"
}
start_infra() {
require_command docker
echo "Starting local Postgres and Kafka..."
compose up -d postgres kafka
wait_for_postgres
wait_for_kafka
}
init_databases() {
echo "Initializing local service databases..."
for db_name in "${databases[@]}"; do
create_database_if_missing "$db_name"
done
}
build_services() {
echo "Building bootJar artifacts..."
echo "Gradle max workers: $gradle_max_workers"
echo "Gradle JVM args: $gradle_jvmargs"
echo "Kotlin daemon JVM args: $kotlin_daemon_jvmargs"
for service_name in "${all_services[@]}"; do
echo "Building bootJar for $service_name..."
"$repo_root/gradlew" "${gradle_common_args[@]}" ":services:$service_name:bootJar"
done
}
service_pid_file() {
local service_name="$1"
printf '%s/%s.pid' "$pid_dir" "$service_name"
}
service_log_file() {
local service_name="$1"
printf '%s/%s.log' "$log_dir" "$service_name"
}
is_pid_running() {
local pid="$1"
[[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1
}
read_service_pid() {
local service_name="$1"
local pid_file
pid_file="$(service_pid_file "$service_name")"
if [[ -f "$pid_file" ]]; then
tr -d '[:space:]' < "$pid_file"
fi
}
find_boot_jar() {
local service_name="$1"
local jar_dir="$repo_root/services/$service_name/build/libs"
local jar_count
local jar_file
if [[ ! -d "$jar_dir" ]]; then
echo "Missing build/libs for $service_name. Run build first." >&2
exit 1
fi
jar_count="$(find "$jar_dir" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | wc -l)"
if [[ "$jar_count" != "1" ]]; then
echo "Expected exactly one bootJar for $service_name, found $jar_count:" >&2
find "$jar_dir" -maxdepth 1 -type f -name '*.jar' -print >&2 || true
exit 1
fi
jar_file="$(find "$jar_dir" -maxdepth 1 -type f -name '*.jar' ! -name '*-plain.jar' | head -n 1)"
printf '%s' "$jar_file"
}
start_service() {
local service_name="$1"
local jar_file
local pid_file
local log_file
local existing_pid
local port="${service_ports[$service_name]}"
local java_cmd=(java)
local parsed_java_opts=()
pid_file="$(service_pid_file "$service_name")"
log_file="$(service_log_file "$service_name")"
existing_pid="$(read_service_pid "$service_name" || true)"
if is_pid_running "$existing_pid"; then
echo "$service_name is already running with pid $existing_pid"
return 0
fi
mkdir -p "$pid_dir" "$log_dir"
jar_file="$(find_boot_jar "$service_name")"
if [[ -n "${JAVA_OPTS:-}" ]]; then
read -r -a parsed_java_opts <<< "$JAVA_OPTS"
java_cmd+=("${parsed_java_opts[@]}")
fi
java_cmd+=(-jar "$jar_file")
echo "Starting $service_name on port $port..."
if [[ "$service_name" == "$config_service" ]]; then
(
cd "$repo_root"
env \
SPRING_PROFILES_ACTIVE=local \
CONFIG_NATIVE_SEARCH_LOCATIONS="file:$config_repo_dir" \
SERVER_PORT="$config_server_port" \
"${java_cmd[@]}" > "$log_file" 2>&1 &
echo "$!" > "$pid_file"
)
else
(
cd "$repo_root"
env \
CONFIG_SERVER_URI="$config_server_uri" \
CONFIG_SERVER_FAIL_FAST=true \
SPRING_PROFILES_ACTIVE="$service_profiles" \
SPRING_CLOUD_CONFIG_PROFILE="$config_profiles" \
SPRING_CLOUD_CONFIG_LABEL= \
SPRING_KAFKA_ADMIN_AUTO_CREATE=true \
SPRING_KAFKA_LISTENER_MISSING_TOPICS_FATAL=false \
CAMUNDA_CLIENT_ENABLED=false \
PLANNING_EXTERNAL_UPDATE_ENABLED=false \
TLE_POLLING_ENABLED=false \
PCP_POSTGRES_HOST="$postgres_host" \
PCP_POSTGRES_PORT="$postgres_port" \
PCP_KAFKA_HOST="$kafka_host" \
PCP_KAFKA_PORT="$kafka_port" \
PCP_PORTS_CONFIG="$config_server_port" \
PCP_PORTS_ADMIN="${service_ports[pcp-srpring-boot-admin-server]}" \
PCP_PORTS_TLE_MONITORING="${service_ports[tle-monitoring-service]}" \
PCP_PORTS_COMPLEX_MISSION_SERVICE="${service_ports[pcp-complex-mission-service]}" \
PCP_PORTS_BALLISTICS="${service_ports[pcp-ballistics-service]}" \
PCP_PORTS_ROUTE_PROCESSING="${service_ports[pcp-route-processing-service]}" \
PCP_PORTS_REQUEST="${service_ports[pcp-request-service]}" \
PCP_PORTS_SLOTS="${service_ports[slots-service]}" \
PCP_PORTS_UI="${service_ports[pcp-ui-service]}" \
PCP_PORTS_STATIONS="${service_ports[pcp-stations-service]}" \
PCP_PORTS_MISSION="${service_ports[pcp-mission-planing-service]}" \
PCP_PORTS_TGU="${service_ports[pcp-tgu-service]}" \
PCP_PORTS_COVERAGE_SCHEME="${service_ports[pcp-coverage-scheme-service]}" \
PCP_PORTS_SATELLITE_CATALOG="${service_ports[pcp-satellite-catalog-service]}" \
PCP_PORTS_DYNAMIC_PLAN="${service_ports[pcp-dynamic-plan-service]}" \
"${java_cmd[@]}" > "$log_file" 2>&1 &
echo "$!" > "$pid_file"
)
fi
echo "$service_name log: $log_file"
}
start_config_server() {
if [[ ! -d "$config_repo_dir" ]]; then
echo "Config repo directory does not exist: $config_repo_dir" >&2
exit 1
fi
start_service "$config_service"
wait_for_http "$config_service" "$config_server_uri/actuator/health"
wait_for_http "$config_service config-repo lookup" "$config_server_uri/pcp-request-service/local"
}
wait_for_service_health() {
local service_name="$1"
local port="${service_ports[$service_name]}"
wait_for_http "$service_name" "http://localhost:$port/actuator/health" "${APP_HEALTH_ATTEMPTS:-90}"
}
start_app_services() {
for service_name in "${app_services[@]}"; do
start_service "$service_name"
if [[ "${WAIT_FOR_APPS:-true}" == "true" ]]; then
wait_for_service_health "$service_name"
fi
done
}
stop_service() {
local service_name="$1"
local pid_file
local pid
local attempt
pid_file="$(service_pid_file "$service_name")"
pid="$(read_service_pid "$service_name" || true)"
if ! is_pid_running "$pid"; then
rm -f "$pid_file"
echo "$service_name is not running."
return 0
fi
echo "Stopping $service_name with pid $pid..."
kill "$pid" >/dev/null 2>&1 || true
for attempt in {1..30}; do
if ! is_pid_running "$pid"; then
rm -f "$pid_file"
return 0
fi
sleep 1
done
echo "Force stopping $service_name with pid $pid..."
kill -9 "$pid" >/dev/null 2>&1 || true
rm -f "$pid_file"
}
stop_services() {
local service_name
local idx
for ((idx = ${#app_services[@]} - 1; idx >= 0; idx--)); do
service_name="${app_services[$idx]}"
stop_service "$service_name"
done
stop_service "$config_service"
}
status_services() {
local service_name
local pid
local log_file
printf '%-36s %-10s %s\n' "service" "pid" "log"
for service_name in "${all_services[@]}"; do
pid="$(read_service_pid "$service_name" || true)"
log_file="$(service_log_file "$service_name")"
if is_pid_running "$pid"; then
printf '%-36s %-10s %s\n' "$service_name" "$pid" "$log_file"
else
printf '%-36s %-10s %s\n' "$service_name" "-" "$log_file"
fi
done
}
tail_logs() {
local service_name="${1:-}"
local log_file
if [[ -z "$service_name" ]]; then
echo "Service name is required for logs command." >&2
exit 1
fi
log_file="$(service_log_file "$service_name")"
if [[ ! -f "$log_file" ]]; then
echo "Log file does not exist: $log_file" >&2
exit 1
fi
tail -f "$log_file"
}
start_all() {
require_command java
build_services
if [[ "${START_INFRA:-true}" == "true" ]]; then
start_infra
if [[ "${INIT_DB:-true}" == "true" ]]; then
init_databases
fi
else
echo "Skipping local infra startup. Expecting Postgres at $postgres_host:$postgres_port and Kafka at $kafka_host:$kafka_port."
fi
start_config_server
start_app_services
echo "Local jar startup requested."
status_services
}
command="${1:-start}"
case "$command" in
start)
start_all
;;
restart)
stop_services
start_all
;;
stop)
stop_services
;;
status)
status_services
;;
build)
build_services
;;
infra-up)
start_infra
if [[ "${INIT_DB:-true}" == "true" ]]; then
init_databases
fi
;;
infra-down)
require_command docker
compose stop kafka postgres
;;
logs)
tail_logs "${2:-}"
;;
help|-h|--help)
usage
;;
*)
usage >&2
exit 1
;;
esac
@@ -0,0 +1,173 @@
# Аудит проекта observatio-terrae
Дата: 2026-06-06. Автор: Claude (Opus 4.8).
Охват: фронтенд `pcp-tgu-ops-ui` + бэкенд-сервисы. Сервис `tle-monitoring-service` по
указанию исключён из анализа.
Метод: статический разбор кода, прогон сборки/тестов фронта, выборочное чтение бэкенд-сервисов
(акцент на недавно дорабатываемых: tgu, mission-planing, complex-mission, request), проверка
конфигов, CORS, обработки ошибок и гигиены репозитория.
## Сводка по состоянию
Базовое здоровье — хорошее. Фронтенд проходит строгую типизацию (`tsc -b`, exit 0) и все
тесты (53 теста в 12 файлах, зелёные); в исходниках нет `console.*`, `any`, `@ts-ignore`.
Бэкенд использует общий `GlobalExceptionHandler` из `pcp-types-lib`, нет `printStackTrace`,
нет `@Disabled`/`@Ignore` тестов. Найденные проблемы — это в основном технический долг,
копипаст-артефакты и один реальный риск надёжности в Camunda-воркерах.
Приоритеты: **B1** (надёжность), **R1/C1** (гигиена/конфиги), затем рефакторинг-долг.
---
## Бэкенд
### B1 (высокий). Camunda job-воркеры молча проглатывают исключения
Файл: [FlowService.kt](../services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt)
Все воркеры объявлены с `autoComplete = false` и обёрнуты в:
```kotlin
catch (ex : Exception) { logger.warn(ex.message) }
```
Если исключение возникает до `client.newCompleteCommand(...).send()`, задача **не завершается и
не падает**: воркер «съел» ошибку. Camunda не получает ни complete, ни fail/incident — BPMN-процесс
зависает молча до таймаута активации, без видимого инцидента и без явной диагностики. Плюс
`logger.warn(ex.message)` теряет stack trace, а `ex.message` может быть `null`.
Рекомендация: в catch завершать задачу через `client.newFailCommand(job.key)` (с retries) либо
бросать исключение наверх, чтобы Camunda создала incident; логировать как `logger.error(msg, ex)`
со стек-трейсом. Это касается `surveyPlan`, `surveyDrop`, `sendPlan`, `confirmPlan` и прочих
воркеров файла.
### B2 (средний). Симуляционная заглушка `Thread.sleep(5000)` в проде-пути
Файл: [FlowService.kt:97](../services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/FlowService.kt#L97)
В `sendPlan` стоит `Thread.sleep(5000)` с комментарием «Иммитация сообщения о получении
подтверждения» — синхронно блокирует поток job-воркера на 5 с и эмулирует приём подтверждения
плана. Для MVP-демо допустимо (см. практику осознанных заглушек), но это нужно снять при
интеграции с реальной шиной подтверждений; пока имитация маскирует отсутствие реального канала и
держит поток пула воркеров.
### B3 (средний). Несогласованные пакеты — копипаст-артефакты
Базовые пакеты ряда сервисов не соответствуют их имени (видимо, форки шаблона):
| Сервис | Пакет | Проблема |
|---|---|---|
| pcp-ballistics-service | `space.nstart.pcp.pcp_request_service` | пакет от request-сервиса |
| pcp-complex-mission-service | `space.nstart.pcp.pcp_satellites_service` | чужое имя |
| pcp-ui-service | `space.nstart.pcp.slots_service` | совпадает со slots-service |
| slots-service | `space.nstart.pcp.slots_service` | тот же пакет, что у ui-service |
| pcp-tgu-service | `space.nstart.pcp_tgu_service` | пропущен сегмент `.pcp.` |
| pcp-request-service | `org.nstart.dep265.requestservice` | другой корень (`org` вместо `space`) |
Последствия: grep/навигация по пакету выводит на «не тот» сервис, IDE-импорты обманчивы,
ui-service и slots-service неотличимы по пакету. Переименование пакетов затрагивает много файлов —
делать осознанно, отдельной задачей; новые сервисы заводить уже с корректным пакетом.
### B4 (низкий). Опечатка «srpring» растиражирована
`pcp-srpring-boot-admin-server` — опечатка в имени каталога и пакета, продублированная в
`settings.gradle.kts`, `build.gradle.kts`, `.gitlab-ci.yml`, `deploy/local-bundle/docker-compose.yml`,
`config-repo/application-docker-local.yaml`. Косметика, но фиксируется во всей инфраструктуре, потому
исправлять дорого — оставить как известный долг или чинить вместе с B3.
### B5 (низкий). Потеря stack trace при логировании
~9 мест вида `logger.warn(ex.message)` / `logger.error(ex.message)` логируют только текст без
исключения. Для диагностики предпочтительно `logger.error("...", ex)`.
### B6 (инфо). Опечатки в лог-сообщениях
Напр. «Утверждение поана» (план), «Иммитация» (имитация) в FlowService.
---
## Фронтенд (pcp-tgu-ops-ui)
### F1 (средний). Дублирование HTTP-/error-обвязки в API-клиентах
`extractErrorText` скопирован в 5 файлах: `tguApi.ts`, `ballisticsApi.ts`, `missionPlanningApi.ts`,
`complexPlanApi.ts`, `tguPlanApi.ts`. Шаблон `try { fetch } catch { TguApiError("network") }` +
разбор `!response.ok` повторяется ~22 раза (7 в missionPlanningApi, 5 в tguPlanApi и т.д.).
В `tguApi.ts` уже есть аккуратный `fetchJson<T>()` — остальные клиенты переписывают то же руками.
Рекомендация: вынести единый `src/api/http.ts` (обёртка `fetchJson` + `extractErrorText` +
маппинг ошибок) и переиспользовать. Сократит ~150 строк и устранит расхождения (см. F5).
### F2 (низкий). Островок нетипизированного JS во вкладке «Группировка»
`features/constellation/*` — это `.jsx`/`.js` (`ConstellationDashboard.jsx`, `sim.js`, `data.js`,
`MapView.jsx`, `Panels.jsx`, `Timeline.jsx`, `util.js`), включён через `allowJs`. Остальное
приложение — строгий TS. Прототипный код подключён в прод через `ConstellationTab.tsx` (обёртка с
`as ComponentType`). Типы в нём фактически `any` — провал строгой типизации в этой ветке UI.
Долг: постепенно перевести на `.tsx` или хотя бы добавить декларации типов на границе.
### F3 (низкий). Мёртвый barrel `constellation/index.js`
`features/constellation/index.js` (реэкспорт `SIM`, `SATELLITES`, `STATIONS`, ...) нигде не
импортируется — можно удалить.
### F4 (низкий). God-компонент `TguEditorTab.tsx` (1727 строк)
Самый крупный файл фронта; держит десятки `useState`/`useRef`, режимы правой панели, построение
маршрутов, валидацию, undo/redo. Хорошо закомментирован, но монолитен. Кандидат на декомпозицию
(вынос хуков `useDraftHistory`, `useRouteBuilder`, разнесение панелей) для снижения связности.
### F5 (инфо). Расхождение приоритета полей ошибки
В `tguApi.extractErrorText` порядок полей `reason, message, error, detail`, а в
`missionPlanningApi.extractErrorText``message, error, reason, detail`. При едином ответе бэка
пользователь увидит разный текст из разных клиентов. Устранится при выносе общей обвязки (F1).
---
## Гигиена репозитория
### R1 (средний). Крупные дампы данных в корне репозитория (в git)
Отслеживаются git и лежат в корне:
- `pcp_requests_converted_create_requests.json` (~228 КБ)
- `response_заданияКубернетс.json` (~157 КБ)
- `response_заданияЕмельянов.json` (~66 КБ)
Это тестовые выгрузки, засоряющие корень и историю. Перенести в `docs/`/`*/src/test/resources`
или убрать из git (оставив в `.gitignore`).
### R2 (низкий). Посторонние артефакты в корне (не в git)
- `node_modules/` в корне + пустой `package-lock.json` (`"packages": {}`, 0 зависимостей) —
корневого Node-проекта нет, фронт живёт в `services/pcp-tgu-ops-ui`. Мусор от случайного
`npm install` в корне; удалить, при желании добавить `/package-lock.json` в `.gitignore`.
- `obj_terra.zip` (~1.5 МБ) в корне — не в git, но захламляет дерево.
### R3 (низкий). 26 устаревших каталогов `bin/` на диске
Скомпилированные `bin/main/...` (gitignored) задваивают исходники: любой `grep` по `services`
выдаёт каждый файл дважды (src + bin) — шум при навигации. Прогнать `./gradlew clean` /
удалить `bin/` каталоги.
---
## Конфигурация и безопасность
### C1 (средний). Несогласованное задание паролей БД в config-repo
В большинстве сервисов пароль зашит как `password: password`, и только
[pcp-tgu-service.yaml](../config-repo/pcp-tgu-service.yaml) использует переопределяемый паттерн
`password: ${DB_PASSWORD:password}`. Для локалки риск низкий, но шаблон важен: привести все
сервисы к `${DB_PASSWORD:...}` (и прочие креды — к env-переменным), чтобы прод/стенды
переопределялись без правки config-repo.
### C2 (низкий). CORS: широкий в ui-service, отсутствует в остальных
[WebConfig.kt](../services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt)
задаёт `allowedOriginPatterns("*")` + `allowCredentials=true` — широко; для прода имеет смысл
сузить origin до конкретных хостов. Остальные сервисы, которые фронт зовёт напрямую (tgu,
mission, request, ballistics, complex, stations), CORS-конфига не имеют — сейчас работает только
потому, что обращение идёт через Vite-прокси/nginx (same-origin). Прямые кросс-доменные вызовы к
ним из браузера упадут — это ограничение стоит держать в курсе при изменении схемы доступа.
---
## Что в порядке (намеренно не правим)
- Заглушки/тест-контроллеры/пустой контракт валидации миссии — осознанные MVP-срезы, не дефекты.
- Прямые вызовы микросервисов из фронта через прокси (без BFF) — принятое решение этапа.
- Naive `LocalDateTime` как локальное настенное время — намеренная договорённость проекта.
## Рекомендованный порядок работ
1. **B1** — починить обработку ошибок в Camunda-воркерах (надёжность процессов).
2. **R1, C1** — убрать дампы из корня; унифицировать env-пароли.
3. **F1** — общий `http.ts`, заодно закрыть F5.
4. **B2** — снять `Thread.sleep`-имитацию при появлении реального канала подтверждений.
5. Фоном: **R2/R3** (чистка), **F2/F4** (типизация constellation, декомпозиция редактора),
**B3/B4** (пакеты/опечатки) — отдельными задачами из-за широкого охвата.
+28 -3
View File
@@ -13,7 +13,7 @@
> Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации
> по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C).
_Обновлено: 2026-06-08_ _Обновлено: 2026-06-11_
--- ---
@@ -21,7 +21,7 @@ _Обновлено: 2026-06-08_
### Карта и отображение ### Карта и отображение
- [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца. - [ ] **#1** — Отображать трассы полос исходя из возможности съёмки КА. Для оптики показывать только участки, попадающие в допустимый угол Солнца.
- [ ] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. - [x] **#2** — При клике в область, где пересекается несколько полос обзора, выводить список этих объектов на правой панели; при выборе элемента — выделять соответствующий объект на карте. _Сделано: на вкладке «Редактор» (где рисуются полосы обзора) клик собирает полосы обзора отдельно от прочих объектов — если под точкой нет иного интерактивного объекта (заявки/маршрута/сброса/станции), а полос обзора несколько (наложение соседних витков), они отдаются списком в правую панель (`MapPickPanel`), как у прочих наложений; одиночная полоса — сразу карточка. Выбор/раскрытие элемента подсвечивает полосу на карте (`selectedObjectId`→`mapFocus.objectId`). Полоса обзора получила карточку (КА + виток): новые поля `revolution`/`spacecraftId` у swath-полигона, проброс в `Tgu2DMapSelection`, ветка swath в `MapInfoPanel`. Клик по заявке/маршруту поверх полос не засоряется полосами (полосы — только когда другого объекта под точкой нет). Файлы: `Tgu2DMapView.handleEditorClick`, `mapTypes.ts`, `mapSelectionTypes.ts`, `TguEditorTab` (сборка сцены), `EditorPanels.MapInfoPanel`._ _Доработка (2026-06-11): когда под точкой есть наложение ≥2 интерактивных объектов (напр. маршрут + заявка), в список выбора теперь добавляются и лежащие под кликом полосы обзора, чтобы их данные тоже были видны. Список упорядочен по приоритету (`editorHitRank`): маршрут съёмки → заявка → полоса обзора (маркеры/станции — перед маршрутом, сбросы — между заявкой и полосой). Одиночный клик по маршруту/заявке по-прежнему сразу выбирает объект (без списка), иначе любой клик в редакторе превращался бы в список из-за сплошного покрытия полосами._
- [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться. - [ ] **#3** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться.
### Группировка ### Группировка
@@ -34,15 +34,39 @@ _Обновлено: 2026-06-08_
### Комплексный план ### Комплексный план
- [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки. - [ ] **#6** — Пересчитывать комплексный план при бронировании новых слотов или при поступлении новой заявки.
- [x] **#44** — Добавить на вкладку «Комплексный план» просмотр тепловой карты (приоритеты/покрытие ячеек). Источник — `priority-map` сервиса заданий (importance ячеек) и/или статистика покрытия прогона; отрисовать как heatmap-слой поверх карты. _Сделано: чекбокс «Тепловая карта» в тулбаре; фоновый слой-заливка ячеек по важности (cool→hot) под контурами плана/заявок; источник `GET /api/pcp-request/v1/cells/priority-map`, не интерактивен (в spatial-индекс не попадает). `cellsApi.ts` + `drawHeatmap.ts` + слой `heatmap` в `Tgu2DMapView`. Клик по объекту (ячейка/включение/заявка) показывает его данные в правом инспекторе `ComplexInfoPanel` (по аналогии с редактором; при наложении — список под точкой клика с раскрытием)._
### Бэкенд / инфраструктура ### Бэкенд / инфраструктура
- [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu).
- [ ] **#47** — Перевести сервисы с Jackson 2 на Jackson 3 и убрать двойной стек. Под Spring Boot 4.0.2 MVC уже десериализует входящий JSON через **Jackson 3** (`tools.jackson.*`), а весь код монорепы написан под **Jackson 2** (`com.fasterxml.jackson`, `versions.jackson_databind=2.18.2`); в `pcp-request-service` для внутреннего парсинга держится отдельный Jackson 2-бин (`JacksonConfig`). Из-за этой рассинхронизации словили баг: Jackson 3 по умолчанию включает `FAIL_ON_NULL_FOR_PRIMITIVES`, и явный `null` на примитивном `Double coverageRequiredPercent` ронял создание заявки (контракт-тесты не ловили — они на Jackson 2). Срочности/EOL-риска нет, это техдолг и консистентность.
- **Сейчас, дёшево (часы):** системно снять класс багов «null на примитиве» — выставить `spring.jackson.deserialization.fail-on-null-for-primitives=false` в config-repo request-service (проверить, что свойство применяется к Jackson 3-бину; если нет — через `Jackson3ObjectMapperBuilderCustomizer`). Не латать каждое поле по очереди.
- **Среднесрок, по сервису:** мигрировать по одному, пилот — `pcp-request-service` (~7 файлов main, ~0.5–1 день). Аннотации **не трогаем**`jackson-annotations` в Jackson 3 остались в пакете `com.fasterxml.jackson.annotation` (у нас это основная масса: `@JsonIgnoreProperties` ×36, `@JsonProperty` ×16, `@JsonInclude` ×15…). Менять только databind-поверхность: `ObjectMapper` ×23, `TypeReference` ×8, `JsonNode` ×5, `jacksonObjectMapper()` ×12 → пакеты `tools.jackson.databind/core/module.kotlin`; в Jackson 3 `JsonProcessingException` стала unchecked. Дропнуть Jackson 2-зависимости и Jackson 2-бин `JacksonConfig`.
- **Главный риск — Kafka:** `KafkaConsumerConfig`/сериализаторы завязаны на `com.fasterxml` ObjectMapper. Перед стартом — спайк (~полдня): умеет ли `spring-kafka` в нашем Boot 4 Jackson 3-сериализаторы. Если нет — Kafka-слой мигрируем отдельно, HTTP/внутренний JSON переводим независимо.
- **Масштаб (src/main):** 67 файлов в 10 сервисах — pcp-dynamic-plan-service 22, pcp-ui-service 16 (выводится из эксплуатации, ср. #40 — можно не мигрировать), pcp-request-service 7, slots-service/pcp-tgu-service по 5, остальные 1–4. **Грубая оценка всей репы — ~6–10 человеко-дней**, основной риск — Kafka. Big-bang не нужен: шаг 1 закрывает причину бага, полную миграцию вести фоново сервис за сервисом.
- [ ] **#45** — Собрать native-образ сервиса (Kotlin/Native, `kompile`) и контейнер на его основе. Цель — быстрый старт и меньший footprint в сравнении с JVM-образом. Уточнить: какой сервис(ы) переводим первым, совместимость с рефлексией/AOT-конфигом (reachability metadata), сборку в CI и базовый образ контейнера.
- [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:5012:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene``visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. - [ ] **#37** — Рассинхрон номеров витков между баллистическими источниками. Для одного и того же КА/времени `flight-line` (трасса, `/api/tgu-planning/spacecraft/{norad}/flight-line`) и `survey-route/build` (контур, pcp-mission-planing) отдают **разный номер витка**: на Emissio 1 (norad 2) 2026-06-06 12:5012:55 трасса помечена витком 142, а контур/сохранённые режимы — витком 144 (по широте ~74° с.ш. согласованы, расходится только нумерация). В редакторе карта фильтрует трассу/полосу обзора по видимому витку (`buildEditorMapScene``visibleRevolutions`), поэтому при сужении селектора до «виток N» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку.
### Проверка сервисов ### Проверка сервисов
- [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности. - [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности.
- [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой. - [ ] **#28** — Проверить связь сервиса заданий и сервиса получения паспорта маршрута между собой.
### Бэкенд / вывод pcp-ui-service из эксплуатации
- [~] **#40** — Перевести все запросы фронта с `pcp-ui-service` на источники и вывести сервис из эксплуатации. `pcp-ui-service` — наследие старого server-rendered UI; новый фронт ходит к доменным сервисам через gateway, и `pcp-ui-service` остаётся в маршрутизации только как **catch-all** для двух поверхностей: `tguApi.ts``/api/tgu-planning/*` (+ flight-line) и `editorApi.ts``/api/current-plans/missions/{id}/modes`. Карта запросов, источники, реальные пути и пошаговый план — в [план-перевода-с-pcp-ui-service.md](план-перевода-с-pcp-ui-service.md). Ключевые моменты: (1) перенести во фронт UX-перевод ошибок решения по плану (сейчас живёт в `TguPlanningService`); (2) `decision`/`confirm-layin` идут в тест-контроллер tgu (`/test/tgu/*`, гейт `tgu.test-controller.enabled`) — поведение не меняем; (3) DTO проксируются как есть, схема фронта не ломается; (4) внешних потребителей у сервиса нет (проверено grep), но перед удалением кода свериться по мёртвым поверхностям (bookings/catalog/rva/com-plan/…). После переезда — снять `ui-catch-all` из `config-repo/pcp-gateway-service.yaml`, убрать сервис из docker-compose/helm/CI/config-repo.
- [x] Шаги 1–7: репойнт фронта на источники (`tguApi.ts``/api/pcp-tgu` + `/test/tgu/*`; flight-line → `ballisticsApi.ts`; `editorApi.ts` modes → `/api/pcp-mission`) и снятие `ui-catch-all` из gateway. Перевод ошибок уже жил во фронте (`errorMessage` в `tguApi.ts`). Typecheck чист.
- [ ] Шаг 8: вывод сервиса из эксплуатации (compose/helm/CI/config-repo/реестр `pcp.services.ui`) и удаление кода — **отложено** до проверки репойнта на живом стенде. Пока сервис задеплоен, но без маршрутов.
### Слоты (вкладка «Слоты»)
- [x] **#41** — Запрос слотового покрытия по заявке. _Сделано (2026-06-11): добавлена отдельная вкладка «Слоты» (вместо размещения на «Заявке»/«Редакторе» — операции со слотами объёмные, их собрали в одном рабочем месте). Оператор выбирает заявку, интервал и фильтры (ветвь витка, признак «Покрытие») и жмёт «Рассчитать слоты» → слоты-кандидаты от slots-service напрямую через gateway (`GET /api/pcp-slots/api/slots/request-cover`, минуя умирающий `pcp-ui-service`, ср. #40). Результат — таблица (КА, виток/ветвь, слот/цикл, время, крен, статус) и отрисовка контуров на 2D-карте поверх контура заявки; клик по строке центрирует карту и подсвечивает контур. Новый роут gateway `id: slots` (`/api/pcp-slots/**`, StripPrefix=2 → slots-service). Файлы: `config-repo/pcp-gateway-service.yaml`, `api/slotsApi.ts`, `model/slotTypes.ts`, `features/tgu-slots/TguSlotsTab.tsx`, регистрация вкладки в `TguActiveTab`/`TguToolbar`/`TguPlanningPage`, стили `.tgu-slots-*`._
- [x] **#42** — Бронирование слотов по заявке. _Сделано (2026-06-11): на вкладке «Слоты» чекбоксами выбираются слоты → «Забронировать» (пакетно, `POST /api/slots/booking/request` с `{requestId, slots:[{satelliteId, slotNum, cycle}]}`) и «Снять бронь» (`DELETE …/booking/request?requestId=`). После операции перечитываются брони заявки (`GET …/booking/by-request/{id}`), забронированные слоты получают бейдж «бронь» в таблице и отдельный цвет (слой `complexPlan`) на карте. Связано с показом забронированных слотов (#3) и пересчётом комплексного плана при бронировании (#6) — последнее пока на бэкенде не триггерится. Уточнить при необходимости идемпотентность повторной брони._
- [x] **Фильтр отображения слотов по суткам** _(2026-06-11): на вкладке «Слоты» добавлен ползунок-фильтр «Сутки» — тот же двуручейный селектор, что «Витки» на «Редакторе» (классы `map-pass-selector*`), наложенный на карту. Слоты группируются по локальным суткам начала (`tn`); выбранный диапазон суток сужает и таблицу, и сцену карты (кандидаты), «Выбрать все» работает по видимым слотам. Новый расчёт раскрывает фильтр на весь диапазон. Клик по слоту на карте теперь не только подсвечивает строку, но и подскроливает её в зону видимости таблицы. Файлы: `features/tgu-slots/SlotDaySelector.tsx` (новый), `features/tgu-slots/TguSlotsTab.tsx`._
- _Доп. (2026-06-11): подсветка активной строки сделана явной (заливка + левый акцентный бар + bold). Скролл к строке — ручной `scrollBy` по `getBoundingClientRect` с центрированием и плавной анимацией: `scrollIntoView({block:"nearest"})` оказался ненадёжен (выбирал не тот скролл-предок / не двигался). Строка ищется по стабильному `data-slot-id`, а не по `ref` (тот при «переезде» между строками мог занулиться). Подсветку ведут только клики по слоту; клик по контуру заявки её не сбрасывает, клик по пустой карте — снимает._
- [x] **Выбор заявки на карте (вместо списка)** _(2026-06-11): на вкладке «Слоты» убран выпадающий список заявок — он не масштабируется на тысячи заявок. Базово все заявки интервала отображаются на карте; клик по заявке (или по наложению нескольких — список с раскрытием) открывает плавающую панель-инспектор справа над картой (общий `MapObjectInspector`) с данными заявки и кнопкой «Выбрать». До выбора в тулбаре вместо списка — имя выбранной заявки или «—», а левая панель показывает фильтр «Заявки на интервал» (С/По + «Загрузить»/«Сегодня»/«7 дней»/«30 дней», как на «Карте») и подсказку «Кликните заявку на карте». После выбора заявки появляется кнопка «Сброс» справа над картой — возвращает окно в состояние «заявка не выбрана, слоты не показаны». Файлы: `features/tgu-slots/TguSlotsTab.tsx`, `features/tgu-map-2d/MapObjectInspector.tsx` (опц. `onSelectRequest` → кнопка «Выбрать» в карточке заявки), стили `.tgu-slots-request*`/`.tgu-slots-reset`/`.tgu-slots-inspector`/`.tgu-slots-requests-panel`._
- [x] **Бронь не отражалась в UI + явная подсветка строки** _(2026-06-11): «не работает кнопка бронь» — диагностировано: POST `booking/request` бронирует корректно и в ответе `BookedSlotDTO` несёт верный `cycle`, но `GET booking/by-request/{id}` отдаёт **базовый** слот в другом cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), из-за чего ключ `sat:slotNum:cycle` не совпадал с рассчитанной строкой → бейдж «бронь» не появлялся, на карте — лишний полигон. Фронт-обход: после брони отмечаем забронированными ровно те рассчитанные слоты (с контуром), что вернул POST; by-request на горячем пути не перечитываем; cancel чистит локально. Заодно подсветка активной строки слота сделана явной (фон + левый акцентный бар + bold). Root-fix (на бэке, отдельно): `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime`/перечисление в `request-cover`) — иначе пред-существующие брони не коррелируют с расчётом. Файлы: `features/tgu-slots/TguSlotsTab.tsx`, стили `.tgu-slots-table tbody tr.is-active`._
- [ ] **#43** — Уточнение статуса слота. На вкладке «Слоты» уже показывается базовый статус слота (`state`: свободен/бронь/занят) и бейдж брони по заявке. Осталось: подтягивать/обновлять актуальный статус занятости по **заложенному** плану (слоты занимаются по `LAID_IN`, а не по `ACCEPT`, ср. #10) и согласовать его источник со slots-service (real-time/после закладки), а также прокинуть статус на вкладки «Карта»/«Редактор» (#3).
### Аутентификация и роли
- [ ] **#46** — Вывести роли на морду и связать их с аутентификацией. На фронте показывать роль(и) пользователя и разграничивать доступ к действиям/вкладкам по роли; источник ролей — аутентификация (токен/headless-auth, `@nstart/auth`). Уточнить: какие роли есть, где они приходят (claims токена / профиль), как маппятся на UI-разрешения (что скрывать/блокировать), и нужен ли серверный enforcement на стороне сервисов, а не только UI-гейтинг.
### Баги ### Баги
- [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы». - [ ] **#8** — Ошибка при попытке принять план на вкладке «Планы».
- [ ] **#39** — Авторасчёт плана выдаёт нереализуемые маршруты съёмки с креном > 45° (напр. `survey-211`, Emissio 1, крен 46.8°). Такой маршрут нельзя построить вручную: фронт зажимает `MAX_ROUTE_ROLL_DEG = 45` (`TguEditorTab.tsx`), а баллистика/геометрия маршрута и фильтр видимости (МПЛ, `abs(kren) <= 45°`) определены только до 45°. _Диагностика (2026-06-08): корень — данные каталога, не логика. slots-service строит крен слота как центр угловой полосы из каталога (`SlotService.kt:903-905`, `gamma=(begin+end)/2`) и предел 45° не учитывает; полосы берутся напрямую из `satellite_slot_angle`. В сиде каталога (`pcp-satellite-catalog-service/.../V1__initial.sql`) у **оптических** Emissio (id 1-6, 22) собственный профиль полос уходит до 50.42°, и полоса №17 [46.08°, 47.51°] даёт центр ровно 46.8°. Версия «посчитали по логике локации» проверена и НЕ подтвердилась: в slots-service нет ветвления по типу КА (оптика/локация) — все КА строятся одним `TestSatelliteImpl`, солвер выбирается стратегией запроса (GREEDY/CONTINUOUS), `type_code` не используется. Reflexio (локация) наоборот имеет узкие полосы ~17-25°. Решение (вернуться позже): либо обрезать/убрать в каталоге оптические полосы за 45° (поз. №16-20 у Emissio), либо добавить в slots-service зажим/отсев слотов по рабочему пределу крена КА. Истинный предел Emissio подтвердить у владельца каталога/баллистиков перед правкой._ - [ ] **#39** — Авторасчёт плана выдаёт нереализуемые маршруты съёмки с креном > 45° (напр. `survey-211`, Emissio 1, крен 46.8°). Такой маршрут нельзя построить вручную: фронт зажимает `MAX_ROUTE_ROLL_DEG = 45` (`TguEditorTab.tsx`), а баллистика/геометрия маршрута и фильтр видимости (МПЛ, `abs(kren) <= 45°`) определены только до 45°. _Диагностика (2026-06-08): корень — данные каталога, не логика. slots-service строит крен слота как центр угловой полосы из каталога (`SlotService.kt:903-905`, `gamma=(begin+end)/2`) и предел 45° не учитывает; полосы берутся напрямую из `satellite_slot_angle`. В сиде каталога (`pcp-satellite-catalog-service/.../V1__initial.sql`) у **оптических** Emissio (id 1-6, 22) собственный профиль полос уходит до 50.42°, и полоса №17 [46.08°, 47.51°] даёт центр ровно 46.8°. Версия «посчитали по логике локации» проверена и НЕ подтвердилась: в slots-service нет ветвления по типу КА (оптика/локация) — все КА строятся одним `TestSatelliteImpl`, солвер выбирается стратегией запроса (GREEDY/CONTINUOUS), `type_code` не используется. Reflexio (локация) наоборот имеет узкие полосы ~17-25°. Решение (вернуться позже): либо обрезать/убрать в каталоге оптические полосы за 45° (поз. №16-20 у Emissio), либо добавить в slots-service зажим/отсев слотов по рабочему пределу крена КА. Истинный предел Emissio подтвердить у владельца каталога/баллистиков перед правкой._
@@ -94,4 +118,5 @@ _Источник и рекомендации: [аудит-сервис-зада
Добавил вручную: Добавил вручную:
- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают - ~~убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают~~ _Решено иначе (2026-06-11): кнопки не убрали, а починили — они не срабатывали из-за `setPointerCapture` на контейнере карты в `onPointerDown` (перехватывал жест, и клик по кнопке внутри карты не доходил). Добавлен гард: нажатие в области `.tgu-2d-map__zoom` не запускает панорамирование/захват, плюс `stopPropagation` на кнопках. Теперь «+/−» (зум) и «□» (вернуть обзор) работают на всех вкладках с картой._
- [x] **Линейка на карте** _(2026-06-11): инструмент измерения расстояния на всех вкладках с картой (Карта, Заявки, Комплексный план, Редактор, Слоты — общий `Tgu2DMapView`). Иконка-линейка в правом нижнем углу рядом с «+/−/□», включается/выключается кликом (тумблер). В активном режиме клик по карте ставит вершины замера, рисуется пунктирная ломаная с подписями накопленного расстояния по дуге большого круга (haversine), в бейдже — итоговая длина; «Сброс» очищает замер, повторный клик по иконке выключает. Реализовано целиком в `Tgu2DMapView` (состояние `rulerActive`/`rulerPoints`), стили `.tgu-2d-map__ruler*`._
@@ -0,0 +1,116 @@
# План перевода запросов с pcp-ui-service на источники
_Составлено: 2026-06-10. Связано с задачей #40 бэклога [бэклог-pcp-tgu-ops-ui.md](бэклог-pcp-tgu-ops-ui.md)._
## Зачем
`pcp-ui-service` — наследие старого server-rendered UI (Thymeleaf-страницы + REST-прокси-обвязка
над доменными сервисами). Новый фронт `pcp-tgu-ui-service` ходит к бэкендам через
`pcp-gateway-service`, и большинство его клиентов уже бьют в доменные сервисы напрямую
(`/api/pcp-tgu`, `/api/pcp-mission`, `/api/pcp-ballistics`, `/api/pcp-complex`,
`/api/pcp-request`). `pcp-ui-service` остаётся в маршрутизации только как **catch-all**
(`ui-catch-all`, `order:1` в `config-repo/pcp-gateway-service.yaml`) для двух поверхностей.
Цель — увести эти две поверхности на источники и вывести `pcp-ui-service` из эксплуатации.
## Кто ещё зависит от pcp-ui-service
Проверено (grep по `config-repo/`, `services/`, `libs/`): **никто, кроме фронта и catch-all
gateway.** Остальные ссылки — это самопрописка сервиса в Spring Boot Admin
(`config-repo/pcp-ui-service.yaml`) и комментарии в `pcp-types-lib` про то, что обогащение
DTO «исторически делалось в ui-service». Восток-запад (межсервис) сервис не вызывает никто.
## Карта запросов фронта, идущих через pcp-ui-service
Через catch-all `pcp-ui-service` сейчас проходят запросы только из **двух** файлов фронта.
Всё остальное (`ballisticsApi`, `complexPlanApi`, `missionPlanningApi`, `requestApi`,
`tguPlanApi`, `stationsApi`) уже ходит мимо ui-service.
### 1. `src/api/tguApi.ts` → `/api/tgu-planning/*` (+ flight-line)
Контроллер `TguPlanningRestController` (`services/pcp-ui-service/.../controller/TguPlanningController.kt`)
проксирует через `TguPlanningService`/`BallisticsService`. Источники и реальные пути:
| Запрос фронта (через ui-service) | HTTP | Реальный источник | Путь на источнике |
|--------------------------------------------------------------|------|------------------------------|----------------------------------------------------|
| `/api/tgu-planning/platforms` | GET | pcp-tgu-service | `/api/platforms` |
| `/api/tgu-planning/plans?historyDays` | GET | pcp-tgu-service | `/api/plans?historyDays` |
| `/api/tgu-planning/spacecraft/{id}/plans?historyDays` | GET | pcp-tgu-service | `/api/plans/{spacecraftId}?historyDays` |
| `/api/tgu-planning/plans/{planId}/decision?decision&reason` | POST | pcp-tgu-service (**TEST**) | `/test/tgu/plans/{planId}/decision` |
| `/api/tgu-planning/plans/{planId}/dismiss` | POST | pcp-tgu-service | `/api/plans/{planId}/dismiss` |
| `/api/tgu-planning/plans/{planId}/restore` | POST | pcp-tgu-service | `/api/plans/{planId}/restore` |
| `/api/tgu-planning/plans/{planId}/confirm-layin?reason` | POST | pcp-tgu-service (**TEST**) | `/test/tgu/plans/{planId}/confirm-layin` |
| `/api/tgu-planning/spacecraft/{noradId}/flight-line` | GET | pcp-ballistics-service | `/api/satellites/{noradId}/flight-line` (+интервал)|
**Что ui-service делает поверх прокси (нельзя потерять при переезде):**
- **Перевод ошибок в человекочитаемые сообщения** для решения по плану (`decision`):
404 → «План не найден.» / «Test controller недоступен или отключён.»; 409 → «План не
является активным планом, ожидающим решения…»; 5xx → «Test controller недоступен…».
Сетевые сбои (`WebClientRequestException`) → 503 «Test controller недоступен…».
При переезде эту UX-обвязку нужно перенести во фронт (`tguApi.ts`/общий `http.ts`, см. #17),
иначе оператор увидит сырой 4xx/5xx.
- `decision` и `confirm-layin` идут в **тест-контроллер** tgu (`/test/tgu/...`), гейтится
`tgu.test-controller.enabled` в `config-repo/pcp-tgu-service.yaml`. Это MVP-канал
подтверждений (ср. #13). При переезде поведение не меняется — но прод-канал решений
остаётся отдельным вопросом.
- DTO (`TguPlanResponse`, `TguPlatformResponse`) проксируются **как есть** (`bodyToFlux`
того же типа) — форма ответа источника и ui-service идентична, фронт не сломается по схеме.
### 2. `src/features/tgu-editor/editorApi.ts` → `/api/current-plans/missions/{planId}/modes`
| Запрос фронта (через ui-service) | HTTP | Реальный источник | Путь на источнике |
|---------------------------------------------------|------|-----------------------------|------------------------------------|
| `/api/current-plans/missions/{planId}/modes` | GET | pcp-mission-planing-service | `/api/missions/{missionId}/modes` |
`CurrentPlansController.modes``MissionService.modesByMission``${settings.mission-service}`
(= `pcp.services.mission` = pcp-mission-planing-service), прямой проброс `List<ModeResponseDTO>`.
Фронтовый `missionPlanningApi.ts` уже ходит в этот же сервис через `/api/pcp-mission` — endpoint
переезжает туда же.
## План перевода (порядок шагов)
> **Статус (2026-06-10): шаги 1–7 выполнены** — фронт ходит в источники напрямую, catch-all
> снят. Шаг 8 (вывод сервиса из эксплуатации) отложен до проверки репойнта на живом стенде;
> до этого pcp-ui-service остаётся задеплоен, но без маршрутов (безвреден). Перевод ошибок
> решения уже жил во фронте (`errorMessage` в `tguApi.ts`) — серверная обвязка ui-service
> дублировала его, при прямом обращении к tgu ничего не потеряно.
1. **flight-line → `/api/pcp-ballistics`.** Перенести `fetchFlightLine` в `ballisticsApi.ts`
(там уже `BASE=/api/pcp-ballistics`), путь `/api/satellites/{noradId}/flight-line` + интервал
`time_start`/`time_stop`. Сверить формат точек `FlightLinePoint` с тем, что отдаёт
баллистика напрямую (ui-service отдавал `collectList().block()` того же flux — форма та же).
2. **platforms / plans / spacecraft-plans → `/api/pcp-tgu`.** Перепривязать GET-методы
`tguApi.ts` на `/api/pcp-tgu` + `/api/platforms`, `/api/plans`, `/api/plans/{id}`.
`tguPlanApi.ts` уже использует этот префикс — переиспользовать его конвенции/`fetchJson`.
3. **dismiss / restore → `/api/pcp-tgu`** на `/api/plans/{id}/dismiss|restore` (реальный API).
4. **decision / confirm-layin → `/api/pcp-tgu`** на `/test/tgu/plans/{id}/decision|confirm-layin`.
**Одновременно** перенести во фронт перевод ошибок (карта статусов → сообщения выше).
Учесть зависимость от `tgu.test-controller.enabled`.
5. **modes → `/api/pcp-mission`.** В `editorApi.ts` заменить
`/api/current-plans/missions/{id}/modes` на `/api/pcp-mission/api/missions/{id}/modes`
(через общий клиент/`apiFetch`). Свериться с `missionPlanningApi.ts`.
6. **Vite/nginx.** Проверить, что `/api/pcp-*` и `/api/pcp-mission` уже проксируются на gateway
(да — единый `/api` → gateway). Спец-обработка `/api/tgu-planning`/`/api/current-plans`
не нужна.
7. **Снять catch-all.** Удалить маршрут `ui-catch-all` из `config-repo/pcp-gateway-service.yaml`
(после того как фронт перестал слать `/api/tgu-planning` и `/api/current-plans`). Проверить,
что никакой `/api/**` больше не должен попадать в ui-service.
8. **Вывод сервиса из эксплуатации.** Убрать `pcp-ui-service` из gateway-реестра-потребителей,
`docker-compose`/helm/CI (`.gitlab/ci/pcp-ui-service.yml`, include в `.gitlab-ci.yml`,
`changes:`-списки), `config-repo/pcp-ui-service.yaml`, Spring Boot Admin. Код сервиса —
архивировать/удалить отдельным шагом после стабилизации.
## Риски и на что смотреть
- **Потеря UX-перевода ошибок решения** — главный функциональный риск. Перенести в фронт
вместе с шагом 4, не откладывать.
- **Тест-контроллер как канал решений** — `decision`/`confirm-layin` остаются на `/test/tgu/*`.
Это сознательный MVP-срез; прод-канал — отдельная задача (ср. #13).
- **Мёртвые поверхности ui-service.** Контроллеры `bookings`, `catalog`, `rva`, `dynamic-plan`,
`tle-analysis`, `tle-comparison`, `com-plan`, `requests`, `map`, `satellites/pdcm`,
`RvaController` и server-rendered страницы новый фронт **не вызывает** — они умирают вместе
с сервисом. Перед удалением кода ещё раз убедиться, что у них нет внешних потребителей
(геопортал и т.п.).
- **Обогащение DTO в ui-service** (`BookedSlotDetailsDTO`: имя/тип КА добавлялись в ui-service).
Если такая обогащённая поверхность понадобится новому фронту — она должна переехать в
источник или во фронт, а не остаться «висеть» в умирающем сервисе. Сейчас новый фронт её
не использует.
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: pcp-gateway-service
description: Helm chart for deploying pcp-gateway-service from GitLab CI/CD.
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,18 @@
{{- define "pcp-gateway-service.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "pcp-gateway-service.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- include "pcp-gateway-service.name" . | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "pcp-gateway-service.labels" -}}
app.kubernetes.io/name: {{ include "pcp-gateway-service.name" . }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
@@ -0,0 +1,68 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "pcp-gateway-service.fullname" . }}
labels:
{{- include "pcp-gateway-service.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "pcp-gateway-service.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
{{- include "pcp-gateway-service.labels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.image.pullSecrets }}
imagePullSecrets:
{{- range . }}
- name: {{ . }}
{{- end }}
{{- end }}
containers:
- name: {{ include "pcp-gateway-service.name" . }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
- name: SPRING_PROFILES_ACTIVE
value: {{ .Values.app.profile | quote }}
- name: CONFIG_SERVER_URI
value: {{ .Values.app.configServerUri | quote }}
- name: SPRING_CLOUD_CONFIG_LABEL
value: {{ .Values.app.configLabel | quote }}
- name: CONFIG_SERVER_FAIL_FAST
value: {{ .Values.app.configServerFailFast | quote }}
- name: SERVER_PORT
value: {{ .Values.service.port | quote }}
{{- with .Values.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
readinessProbe:
httpGet:
path: /actuator/health
port: http
initialDelaySeconds: 20
periodSeconds: 15
timeoutSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health
port: http
initialDelaySeconds: 40
periodSeconds: 20
timeoutSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
@@ -0,0 +1,31 @@
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "pcp-gateway-service.fullname" . }}
labels:
{{- include "pcp-gateway-service.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className | quote }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
- path: {{ .Values.ingress.path | quote }}
pathType: {{ .Values.ingress.pathType }}
backend:
service:
name: {{ include "pcp-gateway-service.fullname" . }}
port:
number: {{ .Values.service.port }}
{{- end }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "pcp-gateway-service.fullname" . }}
labels:
{{- include "pcp-gateway-service.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
app.kubernetes.io/name: {{ include "pcp-gateway-service.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
+10
View File
@@ -0,0 +1,10 @@
app:
profile: dev
configLabel: dev
service:
port: 8080
ingress:
enabled: true
host: pcp-gateway-service.dev.k8s.264.nstart.cloud
@@ -0,0 +1,10 @@
app:
profile: master
configLabel: master
service:
port: 8080
ingress:
enabled: true
host: pcp-gateway-service.master.k8s.264.nstart.cloud
+36
View File
@@ -0,0 +1,36 @@
replicaCount: 1
image:
repository: registry.example.com/pcp/pcp-gateway-service
tag: latest
pullPolicy: IfNotPresent
pullSecrets: []
service:
type: ClusterIP
port: 8080
app:
profile: master
configServerUri: "http://spring-cloud-config-server:38888"
configServerFailFast: "true"
configLabel: master
ingress:
enabled: false
className: ""
annotations: {}
host: ""
path: /
pathType: Prefix
tls: []
extraEnv: []
resources: {}
podAnnotations: {}
podLabels: {}
nameOverride: ""
fullnameOverride: ""
+9
View File
@@ -0,0 +1,9 @@
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,50 @@
group = "space.nstart.pcp"
plugins {
kotlin("jvm")
kotlin("plugin.spring")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
version = "0.0.1"
java {
toolchain {
languageVersion = JavaLanguageVersion.of((property("versions.java") as String).toInt())
}
}
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}
dependencies {
// Реактивный edge: Spring Cloud Gateway (WebFlux-вариант) + конфиг из config-server.
// Маршруты живут в config-repo/pcp-gateway-service.yaml — единый источник правды.
implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webflux")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springframework.boot:spring-boot-starter-actuator")
// Валидация JWT на edge (Keycloak/new-start-id) — включается флагом gateway.security.jwt-enabled.
// Тянет reactive Spring Security (ServerHttpSecurity/@EnableWebFluxSecurity).
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
@@ -0,0 +1,16 @@
package space.nstart.pcp_gateway_service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
/**
* Единая точка входа в бэкенд для фронта (pcp-tgu-ui-service): reverse-proxy поверх
* микросервисов. Маршрутная таблица — в config-repo/pcp-gateway-service.yaml и ссылается
* на реестр `pcp.services.*`, так что топологию знает только gateway, а не браузер.
*/
@SpringBootApplication
class GatewayApplication
fun main(args: Array<String>) {
runApplication<GatewayApplication>(*args)
}
@@ -0,0 +1,51 @@
package space.nstart.pcp_gateway_service.config
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.web.server.SecurityWebFilterChain
/**
* Политика edge. Две взаимоисключающие цепочки, переключаемые флагом
* `gateway.security.jwt-enabled` (завязан на режим входа фронта):
* - выключено (anonymous, текущий дефолт) — пропускаем всё: поведение как при прямых вызовах;
* - включено (закрытый контур new-start-id) — валидируем Bearer JWT один раз здесь, дальше
* gateway по умолчанию пробрасывает заголовок Authorization вниз к сервисам.
*
* Когда JWT выключен, `spring.security.oauth2.resourceserver.jwt.issuer-uri` не задаётся,
* поэтому JwtDecoder не создаётся и старт не падает из-за пустого issuer.
*/
@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
@Bean
@ConditionalOnProperty(prefix = "gateway.security", name = ["jwt-enabled"], havingValue = "true")
fun securedChain(http: ServerHttpSecurity): SecurityWebFilterChain =
http
.csrf { it.disable() }
.authorizeExchange { exchanges ->
exchanges
.pathMatchers(HttpMethod.OPTIONS).permitAll()
.pathMatchers("/healthz", "/actuator/**").permitAll()
.anyExchange().authenticated()
}
.oauth2ResourceServer { it.jwt {} }
.build()
@Bean
@ConditionalOnProperty(
prefix = "gateway.security",
name = ["jwt-enabled"],
havingValue = "false",
matchIfMissing = true,
)
fun openChain(http: ServerHttpSecurity): SecurityWebFilterChain =
http
.csrf { it.disable() }
.authorizeExchange { it.anyExchange().permitAll() }
.build()
}
@@ -0,0 +1,13 @@
spring:
application:
name: pcp-gateway-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
@@ -67,7 +67,10 @@ data class CreateRequestDto(
*/ */
@field:DecimalMin(value = "0", inclusive = false) @field:DecimalMin(value = "0", inclusive = false)
@field:DecimalMax("100") @field:DecimalMax("100")
val coverageRequiredPercent: Double = 100.0, // Nullable с дефолтом: под Jackson 3 (MVC) FAIL_ON_NULL_FOR_PRIMITIVES включён по умолчанию,
// и явный null от клиента падал бы на примитиве Double. Опциональное поле с дефолтом трактуем
// null как «взять дефолт» — при маппинге в сущность коалесим к 100.0.
val coverageRequiredPercent: Double? = 100.0,
/** Параметры оптической съемки. */ /** Параметры оптической съемки. */
@field:Valid @field:Valid
@@ -81,7 +81,7 @@ class RequestService(
remainingArea = geometryArea, remainingArea = geometryArea,
// Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки, // Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки,
// в отличие от importance — важности заявки для карты приоритетов ячеек. // в отличие от importance — важности заявки для карты приоритетов ячеек.
coverageRequiredPercent = request.coverageRequiredPercent, coverageRequiredPercent = request.coverageRequiredPercent ?: 100.0,
importance = importance, importance = importance,
beginDateTime = request.beginDateTime, beginDateTime = request.beginDateTime,
endDateTime = request.endDateTime, endDateTime = request.endDateTime,
@@ -112,6 +112,23 @@ class RequestServiceTest {
assertEquals(80.0, persistedRequests.single().coverageRequiredPercent) assertEquals(80.0, persistedRequests.single().coverageRequiredPercent)
} }
@Test
fun `create request coalesces null coverage required percent to default`() {
val persistedRequests = mutableListOf<RequestEntity>()
doReturn(false)
.`when`(requestRepository)
.existsById(REQUEST_ID)
doAnswer { invocation ->
persistedRequests += invocation.getArgument<RequestEntity>(0)
null
}.`when`(entityManager).persist(any(RequestEntity::class.java))
// Явный null (клиент прислал coverageRequiredPercent: null) трактуем как «взять дефолт».
service.createRequest(createRequestDto(coverageRequiredPercent = null))
assertEquals(100.0, persistedRequests.single().coverageRequiredPercent)
}
@Test @Test
fun `get request by id returns name and importance`() { fun `get request by id returns name and importance`() {
val request = existingRequest() val request = existingRequest()
@@ -296,7 +313,7 @@ class RequestServiceTest {
verify(requestCellRepository).deleteByRequestId(REQUEST_ID) verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
} }
private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto { private fun createRequestDto(coverageRequiredPercent: Double? = 100.0): CreateRequestDto {
return CreateRequestDto( return CreateRequestDto(
id = REQUEST_ID, id = REQUEST_ID,
name = REQUEST_NAME, name = REQUEST_NAME,
@@ -0,0 +1,9 @@
ARG RUNTIME_IMAGE=repo.nstart.cloud/bellsoft/liberica-openjre-alpine:21.0.5
FROM ${RUNTIME_IMAGE}
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
+7 -57
View File
@@ -21,64 +21,14 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# --- ТГУ: маршрутизация на бэкенды (зеркало server.proxy из vite.config.ts). # --- ТГУ: вся маршрутизация /api/* уходит на gateway (Spring Cloud Gateway).
# СЕМАНТИКА (важно повторить точно, иначе бэкенды получат неверный путь): # Gateway сам срезает префиксы pcp-* (StripPrefix) и выбирает бэкенд —
# - префиксы pcp-* СРЕЗАЮТСЯ: trailing-slash в proxy_pass отбрасывает префикс # см. config-repo/pcp-gateway-service.yaml. Здесь прозрачный reverse-proxy:
# location (как rewrite в vite). Пример: /api/pcp-tgu/api/plans → бэкенд /api/plans. # proxy_pass БЕЗ URI-части → путь /api/... передаётся gateway без изменений
# - /api/stations путь СОХРАНЯЕТ (в vite без rewrite): proxy_pass без URI-части. # (он разбирает его сам). Это заменяет прежний набор per-service location'ов.
# - общий /api/ — catch-all (tgu-planning, current-plans, auth) на ui-service/gateway. # Адрес gateway в кластере — pcp-gateway-service:8080 (dev/master-профиль).
# Порядок важен: специфичные префиксы идут РАНЬШЕ общего /api/.
# Цели (UPSTREAM_*) — заглушки: подставьте реальные адреса кластера ЛИБО перенесите
# маршрутизацию на gateway/ingress. Порты dev (vite) даны как ориентир в комментариях.
# Раскомментируйте после подстановки реальных upstream (нерезолвимый хост ломает старт nginx).
# Заголовки proxy_set_header в каждом блоке — те же, что в активном /api/ ниже.
#
# location /api/pcp-request/ { # dev :7005
# proxy_pass http://UPSTREAM_REQUEST/;
# 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/pcp-ballistics/ { # dev :7003
# proxy_pass http://UPSTREAM_BALLISTICS/;
# 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/pcp-mission/ { # dev :7010
# proxy_pass http://UPSTREAM_MISSION/;
# 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/pcp-tgu/ { # dev :7011
# proxy_pass http://UPSTREAM_TGU/;
# 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/pcp-complex/ { # dev :7002
# proxy_pass http://UPSTREAM_COMPLEX/;
# 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/stations/ { # dev :7009 — путь СОХРАНЯЕМ (proxy_pass без / и без URI)
# proxy_pass http://UPSTREAM_STATIONS;
# 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;
# }
#
# Общий catch-all. В деве это ui-service (:7008): /api/tgu-planning, /api/current-plans, /api/auth.
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:8000/api/; # ЗАГЛУШКА: замените на ui-service/gateway proxy_pass http://pcp-gateway-service:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1
View File
@@ -19,6 +19,7 @@
"@nstart/ui": "^0.4.0", "@nstart/ui": "^0.4.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"@tanstack/react-virtual": "^3.14.2",
"d3-geo": "^3.1.1", "d3-geo": "^3.1.1",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
@@ -4,6 +4,64 @@ import { apiFetch } from './apiFetch'
const BASE = '/api/pcp-ballistics' const BASE = '/api/pcp-ballistics'
export type FlightLinePoint = {
time: string
revolution: number
lat: number
long: number
latLeft: number
longLeft: number
latInnerLeft: number
longInnerLeft: number
latInnerRight: number
longInnerRight: number
latRight: number
longRight: number
revSign: 'ASC' | 'DESC'
}
/**
* Трасса полёта КА (flight-line) на интервале для отрисовки на карте/таймлайне из
* pcp-ballistics-service (GET /api/satellites/{norad}/flight-line). Шаг трассы 60 с.
* @param noradId NORAD-идентификатор КА.
* @param fromMs Начало интервала, мс от эпохи.
* @param toMs Конец интервала, мс от эпохи.
*
* @returns Точки трассы полёта на интервале.
*/
export async function fetchFlightLine(
noradId: string,
fromMs: number,
toMs: number,
): Promise<FlightLinePoint[]> {
const params = new URLSearchParams({
time_start: toLocalDateTime(fromMs),
time_stop: toLocalDateTime(toMs),
})
const url = `${BASE}/api/satellites/${encodeURIComponent(noradId)}/flight-line?${params.toString()}`
let response: Response
try {
response = await apiFetch(url, { headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису баллистики.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Ошибка сервиса баллистики.',
'http',
response.status,
)
}
if (!text.trim()) {
return []
}
return JSON.parse(text) as FlightLinePoint[]
}
export type PointVisibilityParam = { export type PointVisibilityParam = {
/** NORAD-идентификатор КА. */ /** NORAD-идентификатор КА. */
noradId: number noradId: number
@@ -0,0 +1,85 @@
import { TguApiError } from '../model/tguTypes'
import { apiFetch } from './apiFetch'
/**
* Карта приоритетов ячеек сетки заданий (pcp-request-service, GET /v1/cells/priority-map).
* Это тот же источник, по которому комплексный план считает покрытие заявок — отдаёт только
* ячейки, на которые спроецированы заявки (с их важностью и контуром), а не весь каталог сетки.
* Используется на вкладке «Комплексный план» для тепловой карты приоритетов.
*/
const BASE = '/api/pcp-request/v1'
/**
* Минимальная важность ячейки, ниже которой ячейку не возвращаем — тот же порог, что и в
* комплексном сервисе (REQUEST_GRID_MIN_IMPORTANCE), чтобы тепловая карта совпадала с тем,
* что реально участвует в расчёте.
*/
export const HEATMAP_MIN_IMPORTANCE = 0.001
/** Фрагмент заявки внутри ячейки сетки приоритетов. */
export type PriorityCellRequest = {
requestId: string
/** Контур фрагмента заявки внутри ячейки, WKT POLYGON. */
contour: string
/** Процент покрытия ячейки фрагментом заявки. */
coveragePercent: number
/** Snapshot важности заявки из request_cells. */
importance: number
}
/** Ячейка сетки приоритетов: центр, важность, контур (WKT POLYGON) и фрагменты заявок. */
export type PriorityCell = {
cellNum: number
latitude: number
longitude: number
importance: number
/** Контур ячейки, WKT POLYGON (lon lat, ...). */
contour: string
/** Фрагменты заявок, пересекающие ячейку. */
requests: PriorityCellRequest[]
}
/**
* Загрузить карту приоритетов ячеек (ячейки с важностью и контуром) для тепловой карты.
* @param minImportance Порог важности (по умолчанию {@link HEATMAP_MIN_IMPORTANCE}).
*
* @returns Список ячеек сетки с важностью и контурами.
*/
export async function fetchPriorityCells(
minImportance: number = HEATMAP_MIN_IMPORTANCE,
): Promise<PriorityCell[]> {
const params = new URLSearchParams({ minImportance: String(minImportance) })
const url = `${BASE}/cells/priority-map?${params}`
let response: Response
try {
response = await apiFetch(url, { method: 'GET', headers: { Accept: 'application/json' } })
} catch {
throw new TguApiError('Не удалось подключиться к сервису заданий.', 'network')
}
const text = await response.text()
if (!response.ok) {
throw new TguApiError(
extractErrorText(text) || 'Не удалось загрузить карту приоритетов.',
'http',
response.status,
)
}
if (!text.trim()) return []
const parsed = JSON.parse(text) as { items?: PriorityCell[] }
return parsed.items ?? []
}
function extractErrorText(body: string): string {
const trimmed = body.trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>
for (const field of ['message', 'error', 'reason', 'detail']) {
const value = parsed[field]
if (typeof value === 'string' && value.trim()) return value
}
} catch {
return trimmed
}
return trimmed
}
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { bookRequestSlots, fetchRequestSlots } from "./slotsApi";
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" };
afterEach(() => vi.restoreAllMocks());
describe("fetchRequestSlots", () => {
it("прокидывает coverageStrategy в query", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots({ ...baseParams, coverageStrategy: "CONTINUOUS" });
const url = String(fetchMock.mock.calls[0][0]);
expect(url).toContain("request-cover");
expect(url).toContain("coverageStrategy=CONTINUOUS");
});
it("не добавляет coverageStrategy, если не задана", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots(baseParams);
expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy");
});
it("пробрасывает AbortSignal в fetch", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const ctrl = new AbortController();
await fetchRequestSlots(baseParams, ctrl.signal);
expect(fetchMock.mock.calls[0][1]?.signal).toBe(ctrl.signal);
});
it("отменённый запрос пробрасывает ошибку отмены", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new DOMException("aborted", "AbortError"));
await expect(fetchRequestSlots(baseParams)).rejects.toThrow();
});
it("на не-ok бросает с текстом ошибки сервера", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 }));
await expect(fetchRequestSlots(baseParams)).rejects.toThrow("boom");
});
});
describe("bookRequestSlots", () => {
it("POST с телом requestId+slots и возвращает забронированные", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse([{ bookedSlotId: 1, satelliteId: 1, slotNum: 2, cycle: 0 }]));
const res = await bookRequestSlots("r1", [{ satelliteId: 1, slotNum: 2, cycle: 0 }]);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("booking/request");
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({
requestId: "r1",
slots: [{ satelliteId: 1, slotNum: 2, cycle: 0 }],
});
expect(res).toHaveLength(1);
});
});
@@ -0,0 +1,123 @@
import type {
BookedSlotDTO,
RequestSlotsParams,
SlotBookingRequest,
SlotDTO,
} from '../model/slotTypes'
import { apiFetch } from './apiFetch'
// slots-service через gateway: /api/pcp-slots/** → StripPrefix=2 → slots-service /api/slots/**
const BASE = '/api/pcp-slots/api/slots'
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const text = await response.text()
if (!text) return `${fallback}: ${response.status}`
try {
const body = JSON.parse(text) as { error?: string; message?: string }
return body.error || body.message || text
} catch {
return text
}
} catch {
return `${fallback}: ${response.status}`
}
}
/**
* Рассчитывает слоты покрытия заявки (slots-service: GET /api/slots/request-cover).
* @param params Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца,
* стратегия автоформирования покрытия).
* @param signal Сигнал отмены (AbortController) — прерывает долгий расчёт.
*
* @returns Список слотов-кандидатов с контурами для карты. При заданной coverageStrategy —
* уже отобранный достаточный набор покрытия (преднабор для брони).
*/
export async function fetchRequestSlots(
params: RequestSlotsParams,
signal?: AbortSignal,
): Promise<SlotDTO[]> {
const query = new URLSearchParams()
query.set('requestId', params.requestId)
query.set('timeStart', params.timeStart)
query.set('timeStop', params.timeStop)
if (params.revSign) query.set('revSign', params.revSign)
if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun))
if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy)
params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, {
headers: { Accept: 'application/json' },
signal,
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось рассчитать слоты'))
}
return response.json() as Promise<SlotDTO[]>
}
/**
* Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request).
* @param requestId Идентификатор заявки.
* @param slots Список слотов (КА, номер слота, цикл).
*
* @returns Забронированные слоты.
*/
export async function bookRequestSlots(
requestId: string,
slots: SlotBookingRequest[],
): Promise<BookedSlotDTO[]> {
const response = await apiFetch(`${BASE}/booking/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ requestId, slots }),
})
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось забронировать слоты'))
}
return response.json() as Promise<BookedSlotDTO[]>
}
/**
* Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request).
* @param requestId Идентификатор заявки.
*
* @returns Сколько броней снято.
*/
export async function cancelRequestBooking(requestId: string): Promise<number> {
const response = await apiFetch(
`${BASE}/booking/request?requestId=${encodeURIComponent(requestId)}`,
{ method: 'DELETE', headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось снять бронь'))
}
const value = Number(await response.text())
return Number.isFinite(value) ? value : 0
}
/**
* Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}).
* @param requestId Идентификатор заявки.
*
* @returns Список забронированных слотов с контурами.
*/
export async function fetchBookedSlotsByRequest(requestId: string): Promise<SlotDTO[]> {
const response = await apiFetch(
`${BASE}/booking/by-request/${encodeURIComponent(requestId)}`,
{ headers: { Accept: 'application/json' } },
)
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Не удалось загрузить брони заявки'))
}
return response.json() as Promise<SlotDTO[]>
}
+11 -43
View File
@@ -5,53 +5,21 @@ import {
type TguPlanDecisionMessage, type TguPlanDecisionMessage,
type TguPlatform, type TguPlatform,
} from '../model/tguTypes' } from '../model/tguTypes'
import { toLocalIso } from '../utils/localTime'
import { apiFetch } from './apiFetch' import { apiFetch } from './apiFetch'
export type FlightLinePoint = {
time: string
revolution: number
lat: number
long: number
latLeft: number
longLeft: number
latInnerLeft: number
longInnerLeft: number
latInnerRight: number
longInnerRight: number
latRight: number
longRight: number
revSign: 'ASC' | 'DESC'
}
/** /**
* Трасса полёта КА (flight-line) на интервале для отрисовки на карте/таймлайне. * Запросы планирования ТГУ идут напрямую в pcp-tgu-service через gateway (`/api/pcp-tgu` →
* @param noradId NORAD-идентификатор КА. * :7011), как и остальные tgu-вызовы (см. tguPlanApi.ts). Решение/подтверждение закладки —
* @param fromMs Начало интервала, мс от эпохи. * на тест-контроллере ТГУ (`/test/tgu/*`, гейт `tgu.test-controller.enabled`, MVP-канал).
* @param toMs Конец интервала, мс от эпохи.
*
* @returns Точки трассы полёта на интервале.
*/ */
export async function fetchFlightLine( const BASE = '/api/pcp-tgu'
noradId: string,
fromMs: number,
toMs: number,
): Promise<FlightLinePoint[]> {
const query = new URLSearchParams({
time_start: toLocalIso(fromMs),
time_stop: toLocalIso(toMs),
})
return fetchJson<FlightLinePoint[]>(
`/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`,
)
}
/** /**
* Список платформ (КА) ТГУ. * Список платформ (КА) ТГУ.
* @returns Платформы планирования ТГУ. * @returns Платформы планирования ТГУ.
*/ */
export async function fetchPlatforms(): Promise<TguPlatform[]> { export async function fetchPlatforms(): Promise<TguPlatform[]> {
return fetchJson<TguPlatform[]>('/api/tgu-planning/platforms') return fetchJson<TguPlatform[]>(`${BASE}/api/platforms`)
} }
/** /**
@@ -64,8 +32,8 @@ export async function fetchPlatforms(): Promise<TguPlatform[]> {
export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> { export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> {
const query = new URLSearchParams({ historyDays: String(historyDays) }) const query = new URLSearchParams({ historyDays: String(historyDays) })
const path = spacecraftId const path = spacecraftId
? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}` ? `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}?${query}`
: `/api/tgu-planning/plans?${query}` : `${BASE}/api/plans?${query}`
return fetchJson<TguPlan[]>(path) return fetchJson<TguPlan[]>(path)
} }
@@ -89,7 +57,7 @@ export async function sendPlanDecision(
} }
return fetchJson<TguPlanDecisionMessage>( return fetchJson<TguPlanDecisionMessage>(
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`, `${BASE}/test/tgu/plans/${encodeURIComponent(planId)}/decision?${query}`,
{ method: 'POST' }, { method: 'POST' },
true, true,
) )
@@ -101,7 +69,7 @@ export async function sendPlanDecision(
* @param planId Идентификатор плана. * @param planId Идентификатор плана.
*/ */
export async function dismissPlan(planId: string): Promise<void> { export async function dismissPlan(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, { await fetchJson<void>(`${BASE}/api/plans/${encodeURIComponent(planId)}/dismiss`, {
method: 'POST', method: 'POST',
}) })
} }
@@ -111,7 +79,7 @@ export async function dismissPlan(planId: string): Promise<void> {
* @param planId Идентификатор плана. * @param planId Идентификатор плана.
*/ */
export async function restorePlan(planId: string): Promise<void> { export async function restorePlan(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, { await fetchJson<void>(`${BASE}/api/plans/${encodeURIComponent(planId)}/restore`, {
method: 'POST', method: 'POST',
}) })
} }
@@ -122,7 +90,7 @@ export async function restorePlan(planId: string): Promise<void> {
* @param planId Идентификатор плана. * @param planId Идентификатор плана.
*/ */
export async function confirmPlanLayin(planId: string): Promise<void> { export async function confirmPlanLayin(planId: string): Promise<void> {
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, { await fetchJson<void>(`${BASE}/test/tgu/plans/${encodeURIComponent(planId)}/confirm-layin`, {
method: 'POST', method: 'POST',
}) })
} }
@@ -10,10 +10,10 @@
Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа, Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа,
так что ConstellationDashboard рендерит реальные данные без изменений разметки. так что ConstellationDashboard рендерит реальные данные без изменений разметки.
============================================================ */ ============================================================ */
import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi"; import { fetchPlatforms, fetchPlans } from "../../api/tguApi";
import { fetchStations, type StationDto } from "../../api/stationsApi"; import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchSatelliteRva } from "../../api/ballisticsApi"; import { fetchSatelliteRva, fetchFlightLine, type FlightLinePoint } from "../../api/ballisticsApi";
import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi"; import { fetchPlanModes, type PlanMode } from "../tgu-editor/editorApi";
import type { TguPlatform, TguPlan } from "../../model/tguTypes"; import type { TguPlatform, TguPlan } from "../../model/tguTypes";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
@@ -9,6 +9,7 @@ import {
type ComplexPlanRunSummary type ComplexPlanRunSummary
} from "../../api/complexPlanApi"; } from "../../api/complexPlanApi";
import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchPriorityCells, type PriorityCell } from "../../api/cellsApi";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields"; import { DateTime24Field } from "../../components/timeFields";
@@ -16,7 +17,9 @@ import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume"; import { formatVolume } from "../../utils/dataVolume";
@@ -46,6 +49,18 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
const [requestsLoading, setRequestsLoading] = useState(false); const [requestsLoading, setRequestsLoading] = useState(false);
const [requestsError, setRequestsError] = useState<string>(); const [requestsError, setRequestsError] = useState<string>();
// Тепловая карта приоритетов ячеек сетки заданий (фоновый слой, тумблер-чекбокс).
const [showHeatmap, setShowHeatmap] = useState(false);
const [priorityCells, setPriorityCells] = useState<PriorityCell[]>([]);
const [heatmapLoading, setHeatmapLoading] = useState(false);
const [heatmapError, setHeatmapError] = useState<string>();
const [heatmapLoaded, setHeatmapLoaded] = useState(false);
// Инспектор объекта карты справа (по аналогии с редактором): выбранные под кликом объекты
// (1 — карточка сразу, ≥2 — список с раскрытием) и id раскрытого в списке.
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
// NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper).
const satelliteIds = useMemo( const satelliteIds = useMemo(
() => () =>
@@ -89,9 +104,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
[showRequests, requestItems, range, platforms] [showRequests, requestItems, range, platforms]
); );
const heatmapPolygons = useMemo<MapPolygon[]>(
() => (showHeatmap ? buildHeatmapPolygons(priorityCells) : []),
[showHeatmap, priorityCells]
);
const scene = useMemo<Tgu2DMapScene>( const scene = useMemo<Tgu2DMapScene>(
() => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), () => ({ polygons: [...heatmapPolygons, ...modePolygons, ...requestPolygons], lines: [], markers: [] }),
[modePolygons, requestPolygons] [heatmapPolygons, modePolygons, requestPolygons]
); );
const layers = useMemo<Tgu2DMapLayersState>( const layers = useMemo<Tgu2DMapLayersState>(
@@ -100,11 +120,12 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
swath: false, swath: false,
planWorks: false, planWorks: false,
complexPlan: true, complexPlan: true,
heatmap: showHeatmap,
stations: false, stations: false,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: showRequests requests: showRequests
}), }),
[showRequests] [showHeatmap, showRequests]
); );
const loadRuns = useCallback(async (): Promise<ComplexPlanRunSummary[]> => { const loadRuns = useCallback(async (): Promise<ComplexPlanRunSummary[]> => {
@@ -121,6 +142,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
const loadRunModes = useCallback(async (runId: number) => { const loadRunModes = useCallback(async (runId: number) => {
setSelectedRunId(runId); setSelectedRunId(runId);
setMapHits([]);
setActiveHitId(undefined);
setLoadingRun(true); setLoadingRun(true);
setError(undefined); setError(undefined);
try { try {
@@ -165,6 +188,57 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
}; };
}, [showRequests, activeInterval]); }, [showRequests, activeInterval]);
// Карта приоритетов не зависит от интервала — грузим один раз при первом включении чекбокса.
// heatmapLoading намеренно НЕ в зависимостях: иначе setHeatmapLoading(true) перезапустил бы
// эффект, а его cleanup отменил бы собственный незавершённый fetch (cancelled=true).
useEffect(() => {
if (!showHeatmap || heatmapLoaded) return;
let cancelled = false;
setHeatmapLoading(true);
setHeatmapError(undefined);
fetchPriorityCells()
.then((cells) => {
if (!cancelled) {
setPriorityCells(cells);
setHeatmapLoaded(true);
}
})
.catch((caught) => {
if (!cancelled) setHeatmapError(caught instanceof Error ? caught.message : "Не удалось загрузить карту приоритетов.");
})
.finally(() => {
if (!cancelled) setHeatmapLoading(false);
});
return () => {
cancelled = true;
};
}, [showHeatmap, heatmapLoaded]);
// Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика.
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
setMapHits(selection ? [selection] : []);
setActiveHitId(selection?.id);
}, []);
const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
// При включённой тепловой карте ячейка приоритета всегда идёт первой в списке и раскрыта
// по умолчанию — оператор сразу видит приоритет точки, а контуры/заявки ниже.
const ordered = showHeatmap
? [...selections].sort((a, b) => Number(b.layer === "heatmap") - Number(a.layer === "heatmap"))
: selections;
setMapHits(ordered);
setActiveHitId(ordered[0]?.id);
}, [showHeatmap]);
const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInfoPanel = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []);
const runCalculation = async () => { const runCalculation = async () => {
if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return; if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return;
setRunning(true); setRunning(true);
@@ -253,26 +327,57 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
> >
{showRequests ? "Скрыть заявки" : "Показать заявки"} {showRequests ? "Скрыть заявки" : "Показать заявки"}
</button> </button>
<label className="tgu-checkbox" title="Тепловая карта приоритетов ячеек сетки заданий">
<input
type="checkbox"
checked={showHeatmap}
onChange={(event) => setShowHeatmap(event.target.checked)}
/>
<span>Тепловая карта</span>
</label>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} /> <div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && ( {displayRun && (
<div className="tgu-map-toolbar__subtitle"> <div className="tgu-map-toolbar__subtitle">
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)}
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
{showRequests ? ` · заявок ${requestItems.length}` : ""} {showRequests ? ` · заявок ${requestItems.length}` : ""}
{showHeatmap ? ` · ячеек ${heatmapPolygons.length}` : ""}
</div> </div>
)} )}
</div> </div>
<div
className="tgu-complex-body"
style={{ gridTemplateColumns: mapHits.length > 0 ? "minmax(0, 1fr) 22rem" : "minmax(0, 1fr) 0" }}
>
<section className="tgu-map-stage"> <section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>} {overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
<Tgu2DMapView <Tgu2DMapView
scene={scene} scene={scene}
layers={layers} layers={layers}
redrawToken={modes.length + requestPolygons.length} redrawToken={modes.length + requestPolygons.length + heatmapPolygons.length}
onObjectSelect={() => undefined} enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
selectedObjectId={activeHitId}
/> />
</section> </section>
{mapHits.length > 0 && (
<aside className="tgu-editor-side-panel">
<MapObjectInspector
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInfoPanel}
modes={modes}
requestItems={requestItems}
priorityCells={priorityCells}
/>
</aside>
)}
</div>
<div className="tgu-map-errors"> <div className="tgu-map-errors">
{satelliteIds.length === 0 && ( {satelliteIds.length === 0 && (
<div className="tgu-alert tgu-alert--warning"> <div className="tgu-alert tgu-alert--warning">
@@ -286,6 +391,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
)} )}
{requestsLoading && <div className="tgu-alert">Загрузка заявок</div>} {requestsLoading && <div className="tgu-alert">Загрузка заявок</div>}
{requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>} {requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>}
{heatmapLoading && <div className="tgu-alert">Загрузка карты приоритетов</div>}
{heatmapError && <div className="tgu-alert tgu-alert--danger">{heatmapError}</div>}
{showHeatmap && heatmapLoaded && !heatmapError && priorityCells.length === 0 && (
<div className="tgu-alert tgu-alert--warning">
Карта приоритетов пуста на ячейки сетки заданий не спроецировано ни одной заявки
(нужна пересборка сетки в сервисе заданий).
</div>
)}
{runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</div>} {runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</div>}
{notice && <div className="tgu-alert">{notice}</div>} {notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>} {error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
@@ -294,6 +407,30 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
); );
} }
/** Ячейки карты приоритетов → полигоны слоя тепловой карты (контур из WKT + важность + № ячейки). */
function buildHeatmapPolygons(cells: PriorityCell[]): MapPolygon[] {
const result: MapPolygon[] = [];
let zIndex = 0;
for (const cell of cells) {
if (!cell.contour) continue;
const rings = parseWktPolygon(cell.contour);
for (const ring of rings) {
if (ring.length < 3) continue;
result.push({
id: `heatmap-cell:${cell.cellNum}`,
name: `Ячейка ${cell.cellNum}`,
kind: "heatmap",
layer: "heatmap",
points: ring,
importance: cell.importance,
cellNum: cell.cellNum,
zIndex: zIndex++
});
}
}
return result;
}
/** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */ /** Контуры режимов (survey) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */
function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] {
const polygons: MapPolygon[] = []; const polygons: MapPolygon[] = [];
@@ -672,6 +672,17 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP
)} )}
</section> </section>
)} )}
{selection.type === "polygon" && selection.layer === "swath" && (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
<dt>Виток</dt>
<dd className="mono">{selection.revolution ?? "—"}</dd>
</dl>
</section>
)}
</div> </div>
); );
} }
@@ -1,10 +1,9 @@
import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react"; import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react";
import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchRequestsForMap } from "../../api/requestApi";
import { fetchStations, type StationDto } from "../../api/stationsApi"; import { fetchStations, type StationDto } from "../../api/stationsApi";
import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi";
import type { RequestMapItem } from "../../model/requestTypes"; import type { RequestMapItem } from "../../model/requestTypes";
import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes"; import { stationPasses, type OrbitPassInfo, type PassRange } from "../tgu-map-2d/geometry/passes";
import { fetchPointVisibility, fetchSatelliteRva, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi"; import { fetchPointVisibility, fetchSatelliteRva, fetchFlightLine, type FlightLinePoint, type PointVisibilityParam, type RvaZone } from "../../api/ballisticsApi";
import { import {
buildSurveyRoute, buildSurveyRoute,
calculateDrops, calculateDrops,
@@ -16,7 +15,9 @@ import {
validateMission, validateMission,
type SurveyRoute type SurveyRoute
} from "../../api/missionPlanningApi"; } from "../../api/missionPlanningApi";
import { abandonDraft, createDraft } from "../../api/tguPlanApi"; import { abandonDraft, createDraft, fetchDraftPlans } from "../../api/tguPlanApi";
import { TguApiError } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { parseLocal, toLocalIso } from "../../utils/localTime"; import { parseLocal, toLocalIso } from "../../utils/localTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
@@ -90,6 +91,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = {
swath: true, swath: true,
planWorks: true, planWorks: true,
complexPlan: false, complexPlan: false,
heatmap: false,
stations: true, stations: true,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: true requests: true
@@ -627,6 +629,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание // слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы». // плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
// Ищет уже открытый DRAFT этого КА с тем же окном, что у базового плана (replace 1:1 совпадает
// по окну). Используется при 409 от createDraft, чтобы подхватить осиротевший черновик.
const adoptExistingDraft = useCallback(async (basePlan: TguPlanUi): Promise<string | undefined> => {
if (!basePlan.spacecraftId) return undefined;
const drafts = await fetchDraftPlans(basePlan.spacecraftId).catch(() => [] as Awaited<ReturnType<typeof fetchDraftPlans>>);
const match = drafts.find(
(draft) =>
Math.abs(parseLocal(draft.startTime) - basePlan.startMs) < 2000 &&
Math.abs(parseLocal(draft.endTime) - basePlan.endMs) < 2000
);
return match?.planId;
}, []);
const ensureDraft = useCallback(async (): Promise<string> => { const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId; if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
@@ -635,11 +650,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDraftPlanId(selectedPlan.planId); setDraftPlanId(selectedPlan.planId);
return selectedPlan.planId; return selectedPlan.planId;
} }
try {
const created = await createDraft(selectedPlan.planId, { mode: "replace" }); const created = await createDraft(selectedPlan.planId, { mode: "replace" });
createdImplicitlyRef.current = true; createdImplicitlyRef.current = true;
setDraftPlanId(created.draftPlanId); setDraftPlanId(created.draftPlanId);
return created.draftPlanId; return created.draftPlanId;
}, [draftPlanId, selectedPlan]); } catch (error) {
// 409: по этому КА уже открыт черновик на это же окно (осиротевший после прошлой сессии —
// авто-отмена при уходе best-effort и могла не отработать). Вместо тупиковой ошибки
// «Conflict» подхватываем существующий черновик того же окна и правим его (replace 1:1 —
// окно совпадает с базой). Не нашли подходящий — пробрасываем читаемую ошибку.
if (error instanceof TguApiError && error.status === 409) {
const adopted = await adoptExistingDraft(selectedPlan);
if (adopted) {
createdImplicitlyRef.current = true;
setDraftPlanId(adopted);
return adopted;
}
throw new TguApiError(
"По этому КА уже есть открытый черновик на это окно. Отмените его во вкладке «Планы» (раздел «Черновики») или продолжите редактирование там.",
"http",
409,
);
}
throw error;
}
}, [draftPlanId, selectedPlan, adoptExistingDraft]);
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId. // Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
@@ -719,7 +755,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
planEnd: toLocalIso(selectedPlan.endMs) planEnd: toLocalIso(selectedPlan.endMs)
}); });
setSurveyRoute(savedRoute); setSurveyRoute(savedRoute);
void refreshDraftModes(draftId); // Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId
// перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый
// контур мелькает на карте, пока не сделаешь что-нибудь ещё.
await refreshDraftModes(draftId);
} catch (error) { } catch (error) {
setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки.");
return; return;
@@ -758,6 +797,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setTarget(undefined); setTarget(undefined);
setTargetPickActive(false); setTargetPickActive(false);
setSelectedShootPass(undefined); setSelectedShootPass(undefined);
// Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика.
setSurveyRoute(undefined);
setRouteWindow(undefined);
}; };
const stationNameById = useCallback( const stationNameById = useCallback(
@@ -816,8 +858,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
}); });
savedDropId = savedDrop.id; savedDropId = savedDrop.id;
savedDropVolumeMb = savedDrop.volumeMb; savedDropVolumeMb = savedDrop.volumeMb;
// Перечитываем включения DRAFT (источник истины) и ревалидируем. // Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия
void refreshDraftModes(draftId); // editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились.
await refreshDraftModes(draftId);
} catch (error) { } catch (error) {
setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс.");
return; return;
@@ -1594,6 +1637,8 @@ function buildEditorMapScene(
kind: "swath", kind: "swath",
layer: "swath", layer: "swath",
points: [...leftEdge, ...rightEdge], points: [...leftEdge, ...rightEdge],
spacecraftId,
revolution,
zIndex: zIndex++ zIndex: zIndex++
}); });
} }
@@ -1,3 +1,5 @@
import { apiFetch } from "../../api/apiFetch";
export type PlanSurveyMode = { export type PlanSurveyMode = {
id: number; id: number;
planId: number; planId: number;
@@ -30,9 +32,10 @@ export type PlanDropMode = {
export type PlanMode = PlanSurveyMode | PlanDropMode; export type PlanMode = PlanSurveyMode | PlanDropMode;
export async function fetchPlanModes(planId: string): Promise<PlanMode[]> { export async function fetchPlanModes(planId: string): Promise<PlanMode[]> {
const response = await fetch(`/api/current-plans/missions/${encodeURIComponent(planId)}/modes`, { const response = await apiFetch(
headers: { Accept: "application/json" } `/api/pcp-mission/api/missions/${encodeURIComponent(planId)}/modes`,
}); { headers: { Accept: "application/json" } }
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Ошибка загрузки включений плана: ${response.status}`); throw new Error(`Ошибка загрузки включений плана: ${response.status}`);
} }
@@ -0,0 +1,346 @@
import type { ReactNode } from "react";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { ComplexPlanMode } from "../../api/complexPlanApi";
import type { PriorityCell } from "../../api/cellsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
/**
* Единый инспектор объектов карты для всех вкладок (Карта, Комплексный план, Редактор):
* показывает данные объекта, выбранного на карте заявки, маршрута/включения, ячейки тепловой
* карты, станции, маркера КА. При наложении объектов (2 под точкой клика) даёт список с
* раскрытием карточки нужного. Данные-обогащение (modes/requestItems/priorityCells) опциональны:
* чего нет, то берётся из самого выбора (имя, статус).
*/
type MapObjectInspectorProps = {
/** Объекты под точкой клика (1 — карточка сразу, ≥2 — список с раскрытием). */
hits: Tgu2DMapSelection[];
/** id раскрытого объекта в списке. */
activeId?: string;
onToggle: (selection: Tgu2DMapSelection) => void;
onClose: () => void;
/** Режимы прогона комплексного плана — для карточки включения (layer "complexPlan"). */
modes?: ComplexPlanMode[];
/** Заявки активного интервала — для карточки заявки (layer "requests"). */
requestItems?: RequestMapItem[];
/** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */
priorityCells?: PriorityCell[];
/** Если задан — в карточке заявки появляется кнопка «Выбрать» (вкладка «Слоты»). */
onSelectRequest?: (requestId: string) => void;
};
export function MapObjectInspector({
hits,
activeId,
onToggle,
onClose,
modes = [],
requestItems = [],
priorityCells = [],
onSelectRequest,
}: MapObjectInspectorProps) {
const renderCard = (selection: Tgu2DMapSelection): ReactNode => {
if (selection.type === "marker") {
return selection.layer === "stations"
? <StationCard selection={selection} />
: <MarkerCard selection={selection} />;
}
if (selection.type === "line") return <LineCard selection={selection} />;
switch (selection.layer) {
case "complexPlan":
return <ModeCard selection={selection} modes={modes} />;
case "requests":
return <RequestCard selection={selection} requestItems={requestItems} onSelectRequest={onSelectRequest} />;
case "heatmap":
return <CellCard selection={selection} priorityCells={priorityCells} />;
case "planWorks":
return <PlanWorkCard selection={selection} />;
default:
return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
}
};
if (hits.length <= 1) {
const selection = hits[0];
return (
<div className="tgu-editor-panel">
<PanelHead title={selection ? selectionTitle(selection) : "Объект"} onClose={onClose} />
{selection ? renderCard(selection) : <div className="tgu-editor-panel__hint">Объект не выбран.</div>}
</div>
);
}
return (
<div className="tgu-editor-panel">
<PanelHead title={`Объекты под курсором · ${hits.length}`} onClose={onClose} />
<div className="tgu-editor-panel__hint">Кликните объект в списке, чтобы раскрыть его данные.</div>
<div className="tgu-editor-pick">
{hits.map((hit, index) => {
const open = hit.id === activeId;
return (
<div className={`tgu-editor-pick__item${open ? " is-open" : ""}`} key={`${hit.id}:${index}`}>
<button type="button" className="tgu-editor-pick__row" onClick={() => onToggle(hit)}>
<i className={`tgu-editor-pick__swatch tgu-editor-pick__swatch--${swatchKind(hit)}`} />
<span className="tgu-editor-pick__title">{hit.name || selectionTitle(hit)}</span>
<small className="tgu-editor-pick__sub">{selectionTitle(hit)}</small>
<span className="tgu-editor-pick__chevron" aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="tgu-editor-pick__detail">{renderCard(hit)}</div>}
</div>
);
})}
</div>
</div>
);
}
type PolygonSelection = Extract<Tgu2DMapSelection, { type: "polygon" }>;
type MarkerSelection = Extract<Tgu2DMapSelection, { type: "marker" }>;
type LineSelection = Extract<Tgu2DMapSelection, { type: "line" }>;
function ModeCard({ selection, modes }: { selection: PolygonSelection; modes: ComplexPlanMode[] }) {
const mode = modes.find((m) => m.id === selection.modeId);
if (!mode) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>КА</dt>
<dd className="mono">{mode.satelliteId}</dd>
<dt>Тип</dt>
<dd>{modeTypeLabel(mode.type)}</dd>
<dt>Виток</dt>
<dd className="mono">{mode.revolution}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(mode.startTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(mode.endTime)}</dd>
<dt>Длительность, с</dt>
<dd className="mono">{mode.duration?.toFixed(1) ?? "—"}</dd>
<dt>Крен, °</dt>
<dd className="mono">{mode.roll != null ? mode.roll.toFixed(1) : "—"}</dd>
<dt>Объём</dt>
<dd className="mono">{mode.volumeMb != null ? formatVolume(mode.volumeMb) : "—"}</dd>
<dt> ячейки</dt>
<dd className="mono">{mode.cellNum || "—"}</dd>
<dt>Источник</dt>
<dd>{mode.source || "—"}</dd>
</dl>
</section>
);
}
function RequestCard({
selection,
requestItems,
onSelectRequest,
}: {
selection: PolygonSelection;
requestItems: RequestMapItem[];
onSelectRequest?: (requestId: string) => void;
}) {
const item = selection.requestId ? requestItems.find((r) => r.id === selection.requestId) : undefined;
if (!item) return <div className="tgu-editor-panel__hint">{selection.name || selection.id}</div>;
return (
<section className="tgu-editor-panel__section">
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{item.id}</dd>
<dt>Название</dt>
<dd>{item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{surveyTypeLabel(item.surveyType)}</dd>
<dt>Начало</dt>
<dd className="mono">{formatIso(item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatIso(item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{item.highPriorityTransmit ? "Да" : "Нет"}</dd>
</dl>
{onSelectRequest && (
<div className="tgu-editor-panel__actions">
<button
className="tgu-button tgu-button--primary"
type="button"
onClick={() => onSelectRequest(item.id)}
>
Выбрать
</button>
</div>
)}
</section>
);
}
function CellCard({ selection, priorityCells }: { selection: PolygonSelection; priorityCells: PriorityCell[] }) {
const cell = selection.cellNum != null ? priorityCells.find((c) => c.cellNum === selection.cellNum) : undefined;
const importance = cell?.importance ?? selection.importance;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt> ячейки</dt>
<dd className="mono">{cell?.cellNum ?? selection.cellNum ?? "—"}</dd>
<dt>Важность</dt>
<dd className="mono">{importance != null ? importance.toFixed(4) : "—"}</dd>
{cell && (
<>
<dt>Центр</dt>
<dd className="mono">{cell.latitude.toFixed(2)}°, {cell.longitude.toFixed(2)}°</dd>
<dt>Заявок</dt>
<dd className="mono">{cell.requests.length}</dd>
</>
)}
</dl>
{cell && cell.requests.length > 0 && (
<div className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">Заявки в ячейке</div>
<dl className="tgu-map-request-details__grid">
{cell.requests.map((req) => (
<ReqFragmentRow key={req.requestId} requestId={req.requestId} coveragePercent={req.coveragePercent} importance={req.importance} />
))}
</dl>
</div>
)}
</section>
);
}
function PlanWorkCard({ selection }: { selection: PolygonSelection }) {
const style = selection.status ? statusStyle(selection.status) : undefined;
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
{selection.planId && (<><dt>План</dt><dd className="mono">{selection.planId}</dd></>)}
{selection.surveyType && (<><dt>Тип съёмки</dt><dd>{surveyTypeLabel(selection.surveyType)}</dd></>)}
</dl>
{style && (
<span className="tgu-map-status-pill">
<i style={{ backgroundColor: style.color }} />
{style.label}
</span>
)}
</section>
);
}
function StationCard({ selection }: { selection: MarkerSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</section>
);
}
function MarkerCard({ selection }: { selection: MarkerSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
{selection.spacecraftId && (<><dt>КА</dt><dd className="mono">{selection.spacecraftId}</dd></>)}
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
</dl>
</section>
);
}
function LineCard({ selection }: { selection: LineSelection }) {
return (
<section className="tgu-editor-panel__section">
<div className="tgu-editor-eyebrow">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Тип</dt>
<dd>{selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА"}</dd>
</dl>
</section>
);
}
function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) {
return (
<>
<dt className="mono" title={requestId}>{requestId.slice(0, 8)}</dt>
<dd className="mono">покрытие {coveragePercent.toFixed(1)} % · важность {importance.toFixed(3)}</dd>
</>
);
}
function PanelHead({ title, onClose }: { title: string; onClose: () => void }) {
return (
<div className="tgu-editor-panel__head">
<h2>{title}</h2>
<button className="tgu-icon-button" type="button" onClick={onClose} title="Закрыть">×</button>
</div>
);
}
/** Заголовок/подпись типа объекта по слою выбора. */
function selectionTitle(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return selection.layer === "stations" ? "Станция" : "Маркер КА";
if (selection.type === "line") return selection.kind === "drop" ? "Сброс на НКПОР" : "Трасса КА";
switch (selection.layer) {
case "complexPlan": return "Включение комплексного плана";
case "heatmap": return "Ячейка приоритета";
case "requests": return "Заявка на съёмку";
case "planWorks": return "Маршрут съёмки";
case "swath": return "Полоса обзора";
default: return "Объект карты";
}
}
/** Класс цветовой метки в списке выбора (стили в tgu-editor.css). */
function swatchKind(selection: Tgu2DMapSelection): string {
if (selection.type === "marker") return selection.layer === "stations" ? "station" : "spacecraftMarkers";
if (selection.type === "line") return selection.kind === "drop" ? "drop" : "tracks";
return selection.layer;
}
function modeTypeLabel(type: string): string {
switch (type) {
case "SURVEY": return "Съёмка";
case "DROP": return "Сброс";
default: return type;
}
}
function surveyTypeLabel(surveyType: string): string {
switch (surveyType) {
case "OPTICS": return "Оптическая";
case "RSA": return "Радиолокационная (РСА)";
case "COMBINED": return "Комбинированная";
default: return surveyType;
}
}
function formatIso(iso: string): string {
return formatLocalDateTime(parseLocal(iso));
}
@@ -1,14 +1,16 @@
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type Tgu2DMapDetailsProps = { type Tgu2DMapDetailsProps = {
selectedPlan?: TguPlanUi; selectedPlan?: TguPlanUi;
selectedObject?: Tgu2DMapSelection;
}; };
export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetailsProps) { /**
* Нижняя плашка контекста вкладки «Карта»: какой план выбран на «Планах» (КА/статус).
* Информация по объектам карты живёт в правом инспекторе (MapObjectInspector), не здесь.
*/
export function Tgu2DMapDetails({ selectedPlan }: Tgu2DMapDetailsProps) {
const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined; const status = selectedPlan ? statusStyle(selectedPlan.status) : undefined;
return ( return (
@@ -34,15 +36,6 @@ export function Tgu2DMapDetails({ selectedPlan, selectedObject }: Tgu2DMapDetail
) : "-"} ) : "-"}
</div> </div>
</div> </div>
<div>
<div className="tgu-map-details__label">Объект карты</div>
<div className="tgu-map-details__value mono">{selectedObject?.name || selectedObject?.id || "-"}</div>
</div>
<div>
<div className="tgu-map-details__label">Работы</div>
<div className="tgu-map-details__value">Работы плана</div>
</div>
</section> </section>
); );
} }
@@ -1,116 +0,0 @@
import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "./model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = {
requests: RequestAtPoint[];
selectedPolygonId?: string;
onHover: (polygonId: string | undefined) => void;
onSelect: (polygonId: string) => void;
onClose: () => void;
};
export function Tgu2DMapRequestsPanel({ requests, selectedPolygonId, onHover, onSelect, onClose }: Props) {
const selectedEntry = selectedPolygonId ? requests.find((r) => r.polygon.id === selectedPolygonId) : undefined;
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Заявки в точке ({requests.length})</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-requests-list">
{requests.map(({ polygon, item }) => (
<button
key={polygon.id}
className={`tgu-map-request-row ${polygon.id === selectedPolygonId ? "is-selected" : ""}`}
type="button"
onMouseEnter={() => onHover(polygon.id)}
onMouseLeave={() => onHover(undefined)}
onClick={() => onSelect(polygon.id)}
>
<i className={`tgu-map-request-row__type req-${item.surveyType.toLowerCase()}`} />
<span className="tgu-map-request-row__body">
<span className="tgu-map-request-row__name">{item.name || item.id}</span>
<span className="tgu-map-request-row__meta">
{formatDateRange(item.beginDateTime, item.endDateTime)}
{" · "}
{item.surveyType}
{" · "}
{item.status}
</span>
</span>
</button>
))}
</div>
{selectedEntry && (
<div className="tgu-map-request-details">
<div className="tgu-map-panel__label">Детали заявки</div>
<dl className="tgu-map-request-details__grid">
<dt>ID</dt>
<dd className="mono">{selectedEntry.item.id}</dd>
<dt>Название</dt>
<dd>{selectedEntry.item.name || "—"}</dd>
<dt>Статус</dt>
<dd>{selectedEntry.item.status}</dd>
<dt>Тип съёмки</dt>
<dd>{selectedEntry.item.surveyType}</dd>
<dt>Начало</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.beginDateTime)}</dd>
<dt>Конец</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.endDateTime)}</dd>
<dt>Важность</dt>
<dd className="mono">{selectedEntry.item.importance}</dd>
<dt>Покрытие</dt>
<dd className="mono">{selectedEntry.item.coveragePercent != null ? `${selectedEntry.item.coveragePercent.toFixed(1)} %` : "—"}</dd>
<dt>КПП</dt>
<dd className="mono">{selectedEntry.item.kpp?.join(", ") || "—"}</dd>
<dt>Приоритет передачи</dt>
<dd>{selectedEntry.item.highPriorityTransmit ? "Да" : "Нет"}</dd>
<dt>Создано</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.createdAt)}</dd>
<dt>Изменено</dt>
<dd className="mono">{formatDateTime(selectedEntry.item.updatedAt)}</dd>
</dl>
</div>
)}
</aside>
);
}
function formatDateTime(value: string): string {
return formatLocalDateTime(parseLocal(value));
}
function formatDateRange(begin: string, end: string): string {
const b = new Date(parseLocal(begin));
const e = new Date(parseLocal(end));
const sameDay =
b.getFullYear() === e.getFullYear() &&
b.getMonth() === e.getMonth() &&
b.getDate() === e.getDate();
const dateOpts: Intl.DateTimeFormatOptions = { day: "2-digit", month: "2-digit" };
const timeOpts: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit" };
const beginStr = `${b.toLocaleDateString("ru-RU", dateOpts)} ${b.toLocaleTimeString("ru-RU", timeOpts)}`;
const endStr = sameDay
? b.toLocaleTimeString("ru-RU", timeOpts)
: `${e.toLocaleDateString("ru-RU", dateOpts)} ${e.toLocaleTimeString("ru-RU", timeOpts)}`;
return `${beginStr} ${endStr}`;
}
@@ -1,43 +0,0 @@
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
type StationSelection = Extract<Tgu2DMapSelection, { type: "marker" }> & { layer: "stations" };
type Props = {
selection: StationSelection;
onClose: () => void;
};
export function Tgu2DMapStationPanel({ selection, onClose }: Props) {
return (
<aside className="tgu-map-requests-panel">
<div className="tgu-panel-heading">
<span>Станция</span>
<button className="tgu-link-button" type="button" onClick={onClose}>
Закрыть
</button>
</div>
<div className="tgu-map-request-details" style={{ flex: 1 }}>
<div className="tgu-map-panel__label">{selection.name}</div>
<dl className="tgu-map-request-details__grid">
<dt>Номер</dt>
<dd className="mono">{selection.stationNumber ?? "—"}</dd>
<dt>Широта</dt>
<dd className="mono">{selection.lat != null ? `${selection.lat.toFixed(4)}°` : "—"}</dd>
<dt>Долгота</dt>
<dd className="mono">{selection.lon != null ? `${selection.lon.toFixed(4)}°` : "—"}</dd>
<dt>Высота, м</dt>
<dd className="mono">{selection.altitude != null ? selection.altitude.toFixed(1) : "—"}</dd>
<dt>Угол места мин, °</dt>
<dd className="mono">{selection.elevationMin != null ? selection.elevationMin.toFixed(1) : "—"}</dd>
<dt>Угол места макс, °</dt>
<dd className="mono">{selection.elevationMax != null ? selection.elevationMax.toFixed(1) : "—"}</dd>
</dl>
</div>
</aside>
);
}
@@ -6,12 +6,10 @@ import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timeli
import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { platformLabel } from "../tgu-planning/tguTimelineMapper";
import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes";
import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes";
import type { MapPolygon } from "./model/mapTypes";
import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers";
import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails";
import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; import { MapObjectInspector } from "./MapObjectInspector";
import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar";
import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel";
import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar";
import { Tgu2DMapView } from "./Tgu2DMapView"; import { Tgu2DMapView } from "./Tgu2DMapView";
import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval";
@@ -58,15 +56,13 @@ export function Tgu2DMapTab({
const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS); const [layers, setLayers] = useState<Tgu2DMapLayersState>(DEFAULT_TGU_2D_MAP_LAYERS);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [redrawToken, setRedrawToken] = useState(0); const [redrawToken, setRedrawToken] = useState(0);
const [selectedObject, setSelectedObject] = useState<Tgu2DMapSelection>();
const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set()); const [hiddenIds, setHiddenIds] = useState<ReadonlySet<string>>(new Set());
const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range)); const [requestsState, setRequestsState] = useState<RequestsState>(() => buildInitialRequestsState(range));
const [stations, setStations] = useState<StationDto[]>([]); const [stations, setStations] = useState<StationDto[]>([]);
const [stationsFetchFailed, setStationsFetchFailed] = useState(false); const [stationsFetchFailed, setStationsFetchFailed] = useState(false);
const [requestsAtPoint, setRequestsAtPoint] = useState<RequestAtPoint[]>([]); // Инспектор объекта карты справа (единый для всех вкладок): объекты под кликом и раскрытый.
const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState<string | undefined>(); const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState<string | undefined>(); const [activeHitId, setActiveHitId] = useState<string>();
const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>();
useEffect(() => { useEffect(() => {
fetchStations() fetchStations()
@@ -183,47 +179,24 @@ export function Tgu2DMapTab({
setLayers((current) => ({ ...current, [key]: !current[key] })); setLayers((current) => ({ ...current, [key]: !current[key] }));
}; };
// Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика.
const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => {
if (selection?.type === "marker" && selection.layer === "stations") { setMapHits(selection ? [selection] : []);
setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" }); setActiveHitId(selection?.id);
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setSelectedObject(undefined);
} else {
setStationPanel(undefined);
setSelectedObject(selection);
}
}, []); }, []);
const handleRequestsAtPoint = useCallback( const handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => {
(polygons: MapPolygon[]) => { setMapHits(selections);
if (polygons.length === 0) { setActiveHitId(selections[0]?.id);
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
return;
}
const joined = polygons.flatMap((polygon) => {
const item = requestsState.items.find((r) => r.id === polygon.requestId);
return item ? [{ polygon, item }] : [];
});
setRequestsAtPoint(joined);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
setStationPanel(undefined);
},
[requestsState.items]
);
const closeRequestsPanel = useCallback(() => {
setRequestsAtPoint([]);
setSelectedRequestPolygonId(undefined);
setHoveredRequestPolygonId(undefined);
}, []); }, []);
const closeStationPanel = useCallback(() => { const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setStationPanel(undefined); setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInfoPanel = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []); }, []);
return ( return (
@@ -236,7 +209,7 @@ export function Tgu2DMapTab({
onToggleLayer={toggleLayer} onToggleLayer={toggleLayer}
/> />
<div className={`tgu-map-content${(requestsAtPoint.length > 0 || stationPanel != null) ? " has-requests-panel" : ""}`}> <div className={`tgu-map-content${mapHits.length > 0 ? " has-side-panel" : ""}`}>
<Tgu2DMapSidebar <Tgu2DMapSidebar
platforms={platforms} platforms={platforms}
selectedPlanId={selectedPlan?.planId} selectedPlanId={selectedPlan?.planId}
@@ -260,23 +233,22 @@ export function Tgu2DMapTab({
scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene} scene={mapInvalidRange ? { polygons: [], lines: [], markers: [] } : scene}
layers={layers} layers={layers}
redrawToken={redrawToken} redrawToken={redrawToken}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect} onObjectSelect={handleObjectSelect}
onRequestsAtPoint={handleRequestsAtPoint} selectedObjectId={activeHitId}
hoveredRequestPolygonId={hoveredRequestPolygonId}
selectedRequestPolygonId={selectedRequestPolygonId}
/> />
</section> </section>
{requestsAtPoint.length > 0 && ( {mapHits.length > 0 && (
<Tgu2DMapRequestsPanel <aside className="tgu-editor-side-panel">
requests={requestsAtPoint} <MapObjectInspector
selectedPolygonId={selectedRequestPolygonId} hits={mapHits}
onHover={setHoveredRequestPolygonId} activeId={activeHitId}
onSelect={setSelectedRequestPolygonId} onToggle={toggleHit}
onClose={closeRequestsPanel} onClose={closeInfoPanel}
requestItems={requestsState.items}
/> />
)} </aside>
{stationPanel != null && (
<Tgu2DMapStationPanel selection={stationPanel} onClose={closeStationPanel} />
)} )}
</div> </div>
@@ -290,7 +262,7 @@ export function Tgu2DMapTab({
)} )}
</div> </div>
<Tgu2DMapDetails selectedPlan={selectedPlan} selectedObject={selectedObject} /> <Tgu2DMapDetails selectedPlan={selectedPlan} />
</main> </main>
); );
} }
@@ -314,6 +286,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string {
return "Работы плана"; return "Работы плана";
case "complexPlan": case "complexPlan":
return "Комплексный план"; return "Комплексный план";
case "heatmap":
return "Тепловая карта";
case "stations": case "stations":
return "Станции"; return "Станции";
case "spacecraftMarkers": case "spacecraftMarkers":
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawBaseMap } from "./canvas/drawBaseMap";
import { drawComplexPlan } from "./canvas/drawComplexPlan"; import { drawComplexPlan } from "./canvas/drawComplexPlan";
import { drawHeatmap } from "./canvas/drawHeatmap";
import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawPlanWorks } from "./canvas/drawPlanWorks";
import { drawRequests } from "./canvas/drawRequests"; import { drawRequests } from "./canvas/drawRequests";
import { drawStations } from "./canvas/drawStations"; import { drawStations } from "./canvas/drawStations";
@@ -23,6 +24,8 @@ type Tgu2DMapViewProps = {
selectedRequestPolygonId?: string; selectedRequestPolygonId?: string;
/** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */ /** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */
selectedObjectId?: string; selectedObjectId?: string;
/** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */
markedObjectIds?: ReadonlySet<string>;
/** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */
focusPoint?: GeoPoint; focusPoint?: GeoPoint;
focusToken?: number; focusToken?: number;
@@ -83,6 +86,7 @@ export function Tgu2DMapView({
hoveredRequestPolygonId, hoveredRequestPolygonId,
selectedRequestPolygonId, selectedRequestPolygonId,
selectedObjectId, selectedObjectId,
markedObjectIds,
focusPoint, focusPoint,
focusToken, focusToken,
enableObjectChooser = false, enableObjectChooser = false,
@@ -111,6 +115,10 @@ export function Tgu2DMapView({
const [tooltip, setTooltip] = useState<TooltipState>(); const [tooltip, setTooltip] = useState<TooltipState>();
// Гео-координаты под курсором — показываем по центру внизу карты. // Гео-координаты под курсором — показываем по центру внизу карты.
const [cursorGeo, setCursorGeo] = useState<GeoPoint>(); const [cursorGeo, setCursorGeo] = useState<GeoPoint>();
// Линейка: включается кнопкой в правом нижнем углу; в активном режиме клик по карте
// добавляет вершину замера, а наложение рисует ломаную с накопленным расстоянием.
const [rulerActive, setRulerActive] = useState(false);
const [rulerPoints, setRulerPoints] = useState<GeoPoint[]>([]);
const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined); const selectedPolygonRef = useRef<MapPolygon | undefined>(undefined);
const selectedLineRef = useRef<MapLine | undefined>(undefined); const selectedLineRef = useRef<MapLine | undefined>(undefined);
const activePolygons = useMemo( const activePolygons = useMemo(
@@ -193,6 +201,9 @@ export function Tgu2DMapView({
drawTiles(context, view, projection, size, handleTileLoad); drawTiles(context, view, projection, size, handleTileLoad);
drawBaseMap(context, projection); drawBaseMap(context, projection);
if (layers.heatmap) {
drawHeatmap(context, visiblePolygons.filter((polygon) => polygon.layer === "heatmap"), projection, size);
}
if (layers.requests) { if (layers.requests) {
drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size); drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size);
} }
@@ -252,6 +263,10 @@ export function Tgu2DMapView({
); );
const onPointerDown = (event: React.PointerEvent) => { const onPointerDown = (event: React.PointerEvent) => {
// Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание
// и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри
// карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали).
if ((event.target as HTMLElement).closest("button")) return;
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
hasDraggedRef.current = false; hasDraggedRef.current = false;
dragRef.current = { dragRef.current = {
@@ -299,6 +314,14 @@ export function Tgu2DMapView({
wrapperRef.current?.releasePointerCapture(event.pointerId); wrapperRef.current?.releasePointerCapture(event.pointerId);
}; };
// Линейка: переключаем режим; при выключении замер очищаем.
const toggleRuler = () => {
setRulerActive((active) => {
if (active) setRulerPoints([]);
return !active;
});
};
// Применить выбор конкретного объекта (из меню выбора либо при единственном попадании): // Применить выбор конкретного объекта (из меню выбора либо при единственном попадании):
// выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect. // выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect.
const applyEditorHit = useCallback((hit: EditorHit) => { const applyEditorHit = useCallback((hit: EditorHit) => {
@@ -309,17 +332,32 @@ export function Tgu2DMapView({
}, [onObjectSelect]); }, [onObjectSelect]);
// Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки, // Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки,
// сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу; // сбросы, станции). 0 → смотрим на полосы обзора; 1 → выбираем сразу;
// ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели). // ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели).
const handleEditorClick = useCallback((screenPoint: ScreenPoint) => { const handleEditorClick = useCallback((screenPoint: ScreenPoint) => {
const geoPoint = projection.unproject(screenPoint); const geoPoint = projection.unproject(screenPoint);
const hits: EditorHit[] = []; const hits: EditorHit[] = [];
// Полосы обзора собираем отдельно: они почти всегда лежат под кликом и не должны
// превращать выбор заявки/маршрута в список. Их список показываем, только когда под точкой
// нет другого интерактивного объекта, а самих полос несколько (наложение соседних витков).
const swathHits: EditorHit[] = [];
const marker = hitTestMarkers(activeMarkers, screenPoint, projection); const marker = hitTestMarkers(activeMarkers, screenPoint, projection);
if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) });
for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) {
if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue; if (polygon.layer === "swath") {
swathHits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
continue;
}
if (
polygon.layer !== "requests" &&
polygon.layer !== "planWorks" &&
polygon.layer !== "complexPlan" &&
polygon.layer !== "heatmap"
) {
continue;
}
hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) });
} }
@@ -329,19 +367,38 @@ export function Tgu2DMapView({
} }
if (hits.length >= 2 && onObjectsAtPoint) { if (hits.length >= 2 && onObjectsAtPoint) {
// Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). // Несколько интерактивных объектов — показываем список. В него добавляем и полосы обзора,
// лежащие под точкой, чтобы их данные тоже были видны. Порядок: маршрут → заявка → полоса.
const combined = [...hits, ...swathHits].sort((a, b) => editorHitRank(a) - editorHitRank(b));
// Внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId).
selectedPolygonRef.current = undefined; selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined; selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined; selectedLineRef.current = undefined;
onObjectsAtPoint(hits.map((hit) => hit.selection)); onObjectsAtPoint(combined.map((hit) => hit.selection));
return; return;
} }
if (hits.length >= 1) { if (hits.length === 1) {
// Единственный интерактивный объект — выбираем сразу (полосы обзора лежат под кликом почти
// всегда, поэтому ради одного объекта список не показываем — иначе любой клик стал бы списком).
applyEditorHit(hits[0]); applyEditorHit(hits[0]);
return; return;
} }
// Ничего «интерактивного» — выбираем верхний прочий полигон (напр. полосу обзора) либо снимаем выбор. // Другого интерактивного объекта под точкой нет — работаем с полосами обзора:
// ≥2 (наложение витков) → список справа; ровно одна → выбираем её.
if (swathHits.length >= 2 && onObjectsAtPoint) {
selectedPolygonRef.current = undefined;
selectedMarkerRef.current = undefined;
selectedLineRef.current = undefined;
onObjectsAtPoint(swathHits.map((hit) => hit.selection));
return;
}
if (swathHits.length === 1) {
applyEditorHit(swathHits[0]);
return;
}
// Совсем ничего интерактивного — выбираем верхний прочий полигон либо снимаем выбор.
const fallback = hitTestPolygons(polygonIndex, geoPoint); const fallback = hitTestPolygons(polygonIndex, geoPoint);
selectedPolygonRef.current = fallback; selectedPolygonRef.current = fallback;
selectedMarkerRef.current = undefined; selectedMarkerRef.current = undefined;
@@ -352,6 +409,11 @@ export function Tgu2DMapView({
const onClick = (event: React.MouseEvent) => { const onClick = (event: React.MouseEvent) => {
if (hasDraggedRef.current) return; if (hasDraggedRef.current) return;
const screenPoint = screenPointFromEvent(event); const screenPoint = screenPointFromEvent(event);
if (rulerActive) {
// Режим линейки: клик добавляет вершину замера, выбор объектов не трогаем.
setRulerPoints((points) => [...points, projection.unproject(screenPoint)]);
return;
}
if (drawMode) { if (drawMode) {
onDrawAddPoint?.(projection.unproject(screenPoint)); onDrawAddPoint?.(projection.unproject(screenPoint));
return; return;
@@ -438,8 +500,22 @@ export function Tgu2DMapView({
})); }));
}; };
const selectedPolygon = selectedPolygonRef.current; // Внутренние ref-выделения (клик по карте) могут указывать на объект, которого уже нет в сцене —
const selectedMarker = selectedMarkerRef.current; // например, контур съёмки, скрытый при входе в режим правки (sceneModes исключает редактируемый
// режим). SVG-оверлей рисуется на каждый рендер из ref, поэтому без сверки с живой сценой старая
// подсветка «висит» поверх карты, пока не кликнешь в другое место. Рисуем только то, что есть в сцене.
const selectedPolygon =
selectedPolygonRef.current && activePolygons.some((p) => p.id === selectedPolygonRef.current!.id)
? selectedPolygonRef.current
: undefined;
const selectedLine =
selectedLineRef.current && scene.lines.some((l) => l.id === selectedLineRef.current!.id)
? selectedLineRef.current
: undefined;
const selectedMarker =
selectedMarkerRef.current && activeMarkers.some((m) => m.id === selectedMarkerRef.current!.id)
? selectedMarkerRef.current
: undefined;
const externalSelectedPolygon = useMemo(() => { const externalSelectedPolygon = useMemo(() => {
const id = selectedRequestPolygonId ?? selectedObjectId; const id = selectedRequestPolygonId ?? selectedObjectId;
return id ? activePolygons.find((p) => p.id === id) : undefined; return id ? activePolygons.find((p) => p.id === id) : undefined;
@@ -450,17 +526,37 @@ export function Tgu2DMapView({
[selectedObjectId, scene.lines] [selectedObjectId, scene.lines]
); );
// Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId). // Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId).
const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]); const overlayLines = uniqueLines([selectedLine, externalSelectedLine]);
const externalHoveredPolygon = useMemo( const externalHoveredPolygon = useMemo(
() => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined,
[hoveredRequestPolygonId, activePolygons] [hoveredRequestPolygonId, activePolygons]
); );
const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]); const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.current]);
// Отмеченные объекты (напр. отобранные под бронь слоты) — постоянная подсветка под обычным выбором.
const markedPolygons = useMemo(
() =>
markedObjectIds && markedObjectIds.size > 0
? activePolygons.filter((p) => markedObjectIds.has(p.id))
: [],
[markedObjectIds, activePolygons]
);
const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]); const overlayMarkers = uniqueMarkers([selectedMarker, hoverMarkerRef.current]);
// Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга.
const rulerCumKm = useMemo(() => {
const out: number[] = [];
let acc = 0;
rulerPoints.forEach((point, i) => {
if (i > 0) acc += haversineKm(rulerPoints[i - 1], point);
out.push(acc);
});
return out;
}, [rulerPoints]);
const rulerTotalKm = rulerCumKm.length > 0 ? rulerCumKm[rulerCumKm.length - 1] : 0;
return ( return (
<div <div
className={`tgu-2d-map ${pickMode || drawMode ? "is-picking" : ""}`} className={`tgu-2d-map ${pickMode || drawMode || rulerActive ? "is-picking" : ""}`}
ref={wrapperRef} ref={wrapperRef}
onClick={onClick} onClick={onClick}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
@@ -478,6 +574,24 @@ export function Tgu2DMapView({
> >
<canvas className="tgu-2d-map__canvas" ref={canvasRef} /> <canvas className="tgu-2d-map__canvas" ref={canvasRef} />
<svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true"> <svg className="tgu-2d-map__overlay" width={size.width} height={size.height} aria-hidden="true">
{markedPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize);
return ([0, projection.worldSize, -projection.worldSize] as const).flatMap((dx) => {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= size.width + 64 && p.y >= -64 && p.y <= size.height + 64;
});
if (!visible) return [];
return (
<polygon
key={`marked:${polygon.id}:${dx}`}
points={pts.map((p) => `${p.x + dx},${p.y}`).join(" ")}
className="is-marked"
/>
);
});
})}
{overlayPolygons.flatMap((polygon) => { {overlayPolygons.flatMap((polygon) => {
const raw = polygon.points.map((point) => projection.project(point)); const raw = polygon.points.map((point) => projection.project(point));
const pts = unwrapX(raw, projection.worldSize); const pts = unwrapX(raw, projection.worldSize);
@@ -568,6 +682,28 @@ export function Tgu2DMapView({
</g> </g>
); );
})()} })()}
{rulerPoints.length > 0 && (() => {
const pts = unwrapX(rulerPoints.map((point) => projection.project(point)), projection.worldSize);
return (
<g className="tgu-2d-map__ruler">
{pts.length >= 2 && (
<polyline
className="tgu-2d-map__ruler-line"
points={pts.map((p) => `${p.x},${p.y}`).join(" ")}
fill="none"
/>
)}
{pts.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r={4} className="tgu-2d-map__ruler-vertex" />
<text x={p.x + 8} y={p.y - 8} className="tgu-2d-map__ruler-label">
{i === 0 ? "0" : formatDistanceKm(rulerCumKm[i])}
</text>
</g>
))}
</g>
);
})()}
</svg> </svg>
{tooltip && ( {tooltip && (
<div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}> <div className="tgu-2d-map__tooltip" style={{ left: tooltip.x, top: tooltip.y }}>
@@ -577,15 +713,69 @@ export function Tgu2DMapView({
)} )}
{cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>} {cursorGeo && <div className="tgu-2d-map__coords">{formatLatLon(cursorGeo)}</div>}
<div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div> <div className="tgu-2d-map__attribution">© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors · © <a href="https://carto.com/attributions" target="_blank" rel="noopener noreferrer">CARTO</a></div>
{rulerActive && (
<div className="tgu-2d-map__ruler-total">
<span className="tgu-2d-map__ruler-total-label">Линейка</span>
<span className="tgu-2d-map__ruler-total-value">
{rulerPoints.length < 2 ? "кликайте по карте" : formatDistanceKm(rulerTotalKm)}
</span>
{rulerPoints.length > 0 && (
<button
type="button"
className="tgu-2d-map__ruler-reset"
onClick={(event) => {
event.stopPropagation();
setRulerPoints([]);
}}
>
Сброс
</button>
)}
</div>
)}
<div className="tgu-2d-map__zoom"> <div className="tgu-2d-map__zoom">
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) }))}>+</button> <button
<button type="button" onClick={() => setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) }))}>-</button> type="button"
<button type="button" onClick={() => setView(INITIAL_VIEW)}></button> className={`tgu-2d-map__zoom-ruler${rulerActive ? " is-active" : ""}`}
title="Линейка — измерение расстояния по карте"
aria-pressed={rulerActive}
onClick={(event) => {
event.stopPropagation();
toggleRuler();
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="2.5" y="8.5" width="19" height="7" rx="1" />
<path d="M6.5 8.5v3M10 8.5v4M13.5 8.5v3M17 8.5v4" />
</svg>
</button>
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.min(8, current.zoom + 0.5) })); }}>+</button>
<button type="button" onClick={(event) => { event.stopPropagation(); setView((current) => ({ ...current, zoom: Math.max(0.8, current.zoom - 0.5) })); }}>-</button>
<button type="button" title="Вернуть обзор" onClick={(event) => { event.stopPropagation(); setView(INITIAL_VIEW); }}></button>
</div> </div>
</div> </div>
); );
} }
/** Расстояние между двумя гео-точками по дуге большого круга (haversine), км. */
function haversineKm(a: GeoPoint, b: GeoPoint): number {
const R = 6371;
const toRad = Math.PI / 180;
const dLat = (b.lat - a.lat) * toRad;
const dLon = (b.lon - a.lon) * toRad;
const lat1 = a.lat * toRad;
const lat2 = b.lat * toRad;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** Человекочитаемое расстояние: метры до 1 км, иначе километры. */
function formatDistanceKm(km: number): string {
if (km < 1) return `${(km * 1000).toFixed(0)} м`;
if (km < 100) return `${km.toFixed(2)} км`;
return `${km.toFixed(1)} км`;
}
/** Подпись координат под курсором: широта/долгота с полушариями. */ /** Подпись координат под курсором: широта/долгота с полушариями. */
function formatLatLon(point: GeoPoint): string { function formatLatLon(point: GeoPoint): string {
const lat = point.lat; const lat = point.lat;
@@ -603,6 +793,8 @@ function layerTitle(layer: MapPolygon["layer"]): string {
return "Полоса обзора"; return "Полоса обзора";
case "complexPlan": case "complexPlan":
return "Комплексный план"; return "Комплексный план";
case "heatmap":
return "Ячейка приоритета";
case "tracks": case "tracks":
return "Трасса КА"; return "Трасса КА";
case "requests": case "requests":
@@ -682,6 +874,26 @@ type EditorHit =
| { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection } | { kind: "polygon"; polygon: MapPolygon; selection: Tgu2DMapSelection }
| { kind: "line"; line: MapLine; selection: Tgu2DMapSelection }; | { kind: "line"; line: MapLine; selection: Tgu2DMapSelection };
// Порядок объектов в списке выбора под точкой: сначала маршрут съёмки, затем заявка,
// в конце — полоса обзора (как фон). Остальные объекты — между ними.
function editorHitRank(hit: EditorHit): number {
if (hit.kind === "marker") return 0; // станции / маркеры КА
if (hit.kind === "line") return 3; // сбросы
switch (hit.polygon.layer) {
case "planWorks": // маршрут съёмки
case "complexPlan":
return 1;
case "requests": // заявка на съёмку
return 2;
case "heatmap":
return 4;
case "swath": // полоса обзора — последней
return 5;
default:
return 6;
}
}
function markerToSelection(marker: MapMarker): Tgu2DMapSelection { function markerToSelection(marker: MapMarker): Tgu2DMapSelection {
return { return {
type: "marker", type: "marker",
@@ -708,7 +920,10 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection {
requestId: polygon.requestId, requestId: polygon.requestId,
surveyType: polygon.surveyType, surveyType: polygon.surveyType,
status: polygon.status, status: polygon.status,
modeId: polygon.modeId modeId: polygon.modeId,
revolution: polygon.revolution,
importance: polygon.importance,
cellNum: polygon.cellNum
}; };
} }
@@ -0,0 +1,86 @@
import type { MapProjection } from "../geometry/mapProjection";
import type { MapPolygon } from "../model/mapTypes";
type Pt = { x: number; y: number };
function unwrapX(points: Pt[], worldSize: number): Pt[] {
if (points.length === 0) return [];
const out: Pt[] = [points[0]];
let prevX = points[0].x;
for (let i = 1; i < points.length; i++) {
let x = points[i].x;
while (x - prevX > worldSize / 2) x -= worldSize;
while (prevX - x > worldSize / 2) x += worldSize;
out.push({ x, y: points[i].y });
prevX = x;
}
return out;
}
/**
* Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности
* (coolhot). Важность нормируется к максимуму набора, поэтому шкала подстраивается под данные.
* Рисуется ПОД контурами плана/заявок, без обводки и hit-теста это фон, а не объекты.
*/
export function drawHeatmap(
ctx: CanvasRenderingContext2D,
cells: MapPolygon[],
projection: MapProjection,
viewport: { width: number; height: number }
) {
if (cells.length === 0) return;
const maxImportance = cells.reduce((max, cell) => Math.max(max, cell.importance ?? 0), 0);
if (maxImportance <= 0) return;
ctx.save();
for (const cell of cells) {
if (cell.points.length < 3) continue;
const raw = cell.points.map((p) => projection.project(p));
const pts = unwrapX(raw, projection.worldSize);
// sqrt-нормировка слегка поднимает слабые ячейки, чтобы они не сливались с фоном.
const t = Math.sqrt(Math.min(1, Math.max(0, (cell.importance ?? 0) / maxImportance)));
ctx.fillStyle = heatColor(t);
for (const dx of [0, projection.worldSize, -projection.worldSize]) {
const visible = pts.some((p) => {
const x = p.x + dx;
return x >= -64 && x <= viewport.width + 64 && p.y >= -64 && p.y <= viewport.height + 64;
});
if (!visible) continue;
ctx.beginPath();
for (let i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(pts[i].x + dx, pts[i].y);
else ctx.lineTo(pts[i].x + dx, pts[i].y);
}
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
}
/**
* Цвет тепловой карты по нормированной важности t[0,1]: бирюзовый (низкая)
* жёлтый красный (высокая). Альфа растёт с важностью, чтобы «холодные» ячейки были легче.
*/
function heatColor(t: number): string {
// Кусочно-линейная палитра: 0 → #2BD0C4 (cool), 0.5 → #F5D020 (warm), 1 → #F03B20 (hot).
const cool = [43, 208, 196];
const warm = [245, 208, 32];
const hot = [240, 59, 32];
let rgb: number[];
if (t < 0.5) {
rgb = mix(cool, warm, t / 0.5);
} else {
rgb = mix(warm, hot, (t - 0.5) / 0.5);
}
const alpha = 0.35 + 0.4 * t;
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha.toFixed(3)})`;
}
function mix(a: number[], b: number[], k: number): number[] {
return [
Math.round(a[0] + (b[0] - a[0]) * k),
Math.round(a[1] + (b[1] - a[1]) * k),
Math.round(a[2] + (b[2] - a[2]) * k),
];
}
@@ -24,6 +24,7 @@ export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint):
const hits: MapPolygon[] = []; const hits: MapPolygon[] = [];
for (const entry of index.entries) { for (const entry of index.entries) {
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
const inBbox = const inBbox =
bboxContainsPoint(entry.bbox, point) || bboxContainsPoint(entry.bbox, point) ||
bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) || bboxContainsPoint(entry.bbox, { lon: point.lon + 360, lat: point.lat }) ||
@@ -46,6 +47,7 @@ export function hitTestPolygons(index: PolygonSpatialIndex, point: GeoPoint): Ma
let selected: MapPolygon | undefined; let selected: MapPolygon | undefined;
for (const entry of index.entries) { for (const entry of index.entries) {
if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён
// Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped. // Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped.
const inBbox = const inBbox =
bboxContainsPoint(entry.bbox, point) || bboxContainsPoint(entry.bbox, point) ||
@@ -3,6 +3,7 @@ export type Tgu2DMapLayerKey =
| "swath" | "swath"
| "planWorks" | "planWorks"
| "complexPlan" | "complexPlan"
| "heatmap"
| "stations" | "stations"
| "spacecraftMarkers" | "spacecraftMarkers"
| "requests"; | "requests";
@@ -38,6 +39,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = {
swath: false, swath: false,
planWorks: true, planWorks: true,
complexPlan: false, complexPlan: false,
heatmap: false,
stations: true, stations: true,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: true requests: true
@@ -5,7 +5,7 @@ export type Tgu2DMapSelection =
type: "polygon"; type: "polygon";
id: string; id: string;
name: string; name: string;
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap";
planId?: string; planId?: string;
spacecraftId?: string; spacecraftId?: string;
requestId?: string; requestId?: string;
@@ -13,6 +13,12 @@ export type Tgu2DMapSelection =
status?: TguPlanStatus; status?: TguPlanStatus;
/** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */
modeId?: number; modeId?: number;
/** Номер витка (layer "swath") — для карточки полосы обзора. */
revolution?: number;
/** Важность ячейки тепловой карты (layer "heatmap"). */
importance?: number;
/** Номер ячейки сетки заданий (layer "heatmap"). */
cellNum?: number;
} }
| { | {
type: "line"; type: "line";
@@ -24,8 +24,8 @@ export type MapViewState = {
export type MapPolygon = { export type MapPolygon = {
id: string; id: string;
name: string; name: string;
kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan"; kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan" | "heatmap";
layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap";
points: GeoPoint[]; points: GeoPoint[];
status?: TguPlanStatus; status?: TguPlanStatus;
planId?: string; planId?: string;
@@ -34,6 +34,14 @@ export type MapPolygon = {
surveyType?: string; surveyType?: string;
/** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */
modeId?: number; modeId?: number;
/** Номер витка (layer "swath") — для карточки полосы обзора. */
revolution?: number;
/** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */
importance?: number;
/** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */
cellNum?: number;
/** false — контур рисуется, но исключён из hit-теста/выбора (напр. контур заявки на вкладке «Слоты»). */
pickable?: boolean;
zIndex: number; zIndex: number;
}; };
@@ -52,6 +52,7 @@ describe("tgu2DMapInterval", () => {
swath: false, swath: false,
planWorks: true, planWorks: true,
complexPlan: false, complexPlan: false,
heatmap: false,
stations: true, stations: true,
spacecraftMarkers: false, spacecraftMarkers: false,
requests: true requests: true
@@ -82,14 +82,20 @@ export function TguPlanDetails({
const duration = formatDuration(plan.endMs - plan.startMs); const duration = formatDuration(plan.endMs - plan.startMs);
// Окно решения закрылось, если с момента старта плана прошло больше grace-периода. // Окно решения закрылось, если с момента старта плана прошло больше grace-периода.
const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000; const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000;
// Закладку нельзя подтверждать, пока начало плана ещё в будущем: план закладывается на КА // Начало плана ещё в будущем. Нужно и для закладки (план закладывается по факту старта,
// по факту наступления своего старта, а не заранее. // не заранее), и для replace-черновика (после старта окно выдачи заморожено — бэк вернёт 422).
const layinStartInFuture = plan.startMs > Date.now(); const startInFuture = plan.startMs > Date.now();
// «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для // «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для
// персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция, // персистентных правимых баз, у которых старт ещё не наступил. DRAFT уже имеет свои действия;
// не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward). // PLANNED — виртуальная проекция, не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован);
const canCreateDraftFromPlan = // стартовавший план — окно выдачи заморожено (CreateDraftUseCase.createReplaceDraft → 422),
// продолжение возможно только fork-forward (отдельный поток). Гард зеркалит редактор, чтобы
// не предлагать кнопку, которая заведомо упадёт «Unprocessable Content».
const draftableStatus =
plan.status !== "DRAFT" && plan.status !== "PLANNED" && plan.status !== "LAID_IN"; plan.status !== "DRAFT" && plan.status !== "PLANNED" && plan.status !== "LAID_IN";
const canCreateDraftFromPlan = draftableStatus && startInFuture;
// Статус позволял бы replace-черновик, но план уже стартовал — объясняем, почему кнопки нет.
const draftBlockedByStart = draftableStatus && !startInFuture;
const reject = () => { const reject = () => {
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED"); const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
@@ -229,15 +235,23 @@ export function TguPlanDetails({
</div> </div>
)} )}
{draftBlockedByStart && (
<div className="tgu-alert tgu-alert--warning">
Черновик-копию (replace, то же окно) можно создать только до старта плана окно
выдачи уже заморожено. Продолжение цепочки на будущую ЗРВ (fork-forward) выполняется
отдельно.
</div>
)}
{plan.status === "AWAITING_LAYIN" && onConfirmLayin && ( {plan.status === "AWAITING_LAYIN" && onConfirmLayin && (
<div className="tgu-details__test-block"> <div className="tgu-details__test-block">
<div className="tgu-details__test-label">ТЕСТОВЫЙ ФУНКЦИОНАЛ</div> <div className="tgu-details__test-label">ТЕСТОВЫЙ ФУНКЦИОНАЛ</div>
<button <button
className="tgu-button tgu-button--accept" className="tgu-button tgu-button--accept"
type="button" type="button"
disabled={decisionInFlight || layinConflict || layinStartInFuture} disabled={decisionInFlight || layinConflict || startInFuture}
title={ title={
layinStartInFuture startInFuture
? "Начало плана ещё не наступило — подтвердить закладку можно только после его старта" ? "Начало плана ещё не наступило — подтвердить закладку можно только после его старта"
: layinConflict : layinConflict
? "По КА уже заложен план со стартом в пределах ±10 минут — закладка недоступна" ? "По КА уже заложен план со стартом в пределах ±10 минут — закладка недоступна"
@@ -247,12 +261,12 @@ export function TguPlanDetails({
> >
Подтвердить закладку Подтвердить закладку
</button> </button>
{layinStartInFuture && ( {startInFuture && (
<div className="tgu-details__test-hint"> <div className="tgu-details__test-hint">
Начало плана ещё в будущем подтвердить закладку можно только после наступления его старта. Начало плана ещё в будущем подтвердить закладку можно только после наступления его старта.
</div> </div>
)} )}
{!layinStartInFuture && layinConflict && ( {!startInFuture && layinConflict && (
<div className="tgu-details__test-hint"> <div className="tgu-details__test-hint">
По КА уже есть заложенный план со стартом в пределах ±10 минут подтвердить нельзя. По КА уже есть заложенный план со стартом в пределах ±10 минут подтвердить нельзя.
</div> </div>
@@ -14,6 +14,7 @@ import { ConstellationTab } from "../constellation/ConstellationTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { TguSlotsTab } from "../tgu-slots/TguSlotsTab";
import { import {
abandonDraft, abandonDraft,
createDraft, createDraft,
@@ -587,6 +588,12 @@ export function TguPlanningPage() {
</div> </div>
)} )}
{visitedTabs.has("slots") && (
<div style={tabStyle("slots")}>
<TguSlotsTab range={appliedRange} />
</div>
)}
{visitedTabs.has("constellation") && ( {visitedTabs.has("constellation") && (
<div style={tabStyle("constellation")}> <div style={tabStyle("constellation")}>
<ConstellationTab /> <ConstellationTab />
@@ -26,6 +26,11 @@ export function TguToolbar({
onApply, onApply,
onQuickRange onQuickRange
}: TguToolbarProps) { }: TguToolbarProps) {
// Глобальный диапазон «С … По» относится только к вкладке «Планы». Остальные вкладки имеют свой
// источник интервала: «Карта» и «Заявки» — фильтр дат в левой панели, «Комплексный план» — свой
// диапазон внутри вкладки, «Редактор» отражает выбранный на «Планах» план (его окно задаётся
// планом), «Группировка» диапазоном не пользуется. Поэтому панель показываем только на «Планах».
const showRange = activeTab === "timeline";
return ( return (
<header className="tgu-toolbar"> <header className="tgu-toolbar">
<div className="tgu-toolbar__row"> <div className="tgu-toolbar__row">
@@ -83,6 +88,15 @@ export function TguToolbar({
> >
Заявки Заявки
</button> </button>
<button
className={activeTab === "slots" ? "is-active" : ""}
type="button"
role="tab"
aria-selected={activeTab === "slots"}
onClick={() => onTabChange("slots")}
>
Слоты
</button>
<button <button
className={activeTab === "constellation" ? "is-active" : ""} className={activeTab === "constellation" ? "is-active" : ""}
type="button" type="button"
@@ -93,7 +107,10 @@ export function TguToolbar({
Группировка Группировка
</button> </button>
</div> </div>
</div>
{showRange && (
<div className="tgu-toolbar__row tgu-toolbar__row--range">
<label className="tgu-field"> <label className="tgu-field">
<span>С</span> <span>С</span>
<DateTime24Field value={fromValue} onChange={onFromChange} /> <DateTime24Field value={fromValue} onChange={onFromChange} />
@@ -116,6 +133,7 @@ export function TguToolbar({
7 суток 7 суток
</button> </button>
</div> </div>
)}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>} {error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header> </header>
); );
@@ -1,7 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { RequestMapItem } from "../../model/requestTypes";
import type { MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
/** Заявка под точкой клика на карте: полигон-проекция заявки и сама заявка. */
export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem };
type Props = { type Props = {
requests: RequestAtPoint[]; requests: RequestAtPoint[];
selectedPolygonId?: string; selectedPolygonId?: string;
@@ -5,7 +5,7 @@ import type { RequestMapItem } from "../../model/requestTypes";
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel"; import type { RequestAtPoint } from "./RequestDetailsPanel";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { generateUuid } from "../../utils/uuid"; import { generateUuid } from "../../utils/uuid";
@@ -123,7 +123,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
); );
const layers = useMemo( const layers = useMemo(
() => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, stations: showStations, spacecraftMarkers: false, requests: true }), () => ({ tracks: false, swath: false, planWorks: false, complexPlan: false, heatmap: false, stations: showStations, spacecraftMarkers: false, requests: true }),
[showStations] [showStations]
); );
@@ -0,0 +1,115 @@
import { useRef } from "react";
/** Сутки расчёта слотов: индекс (1-based), начало суток (мс, локально), метка ДД.ММ и число слотов. */
export type SlotDay = { index: number; dayMs: number; label: string; count: number };
type SlotDaySelectorProps = {
days: SlotDay[];
fromIndex: number;
toIndex: number;
onFromChange: (idx: number) => void;
onToChange: (idx: number) => void;
};
/**
* Двуручейный фильтр отображения слотов по суткам визуально тот же ползунок, что «Витки» на
* вкладке «Редактор» (классы map-pass-selector*). Накладывается на карту; сужает и таблицу, и сцену.
*/
export function SlotDaySelector({ days, fromIndex, toIndex, onFromChange, onToChange }: SlotDaySelectorProps) {
const trackRef = useRef<HTMLDivElement>(null);
const n = days.length;
if (n === 0) return null;
const fromDay = days[Math.max(0, Math.min(fromIndex - 1, n - 1))];
const toDay = days[Math.max(0, Math.min(toIndex - 1, n - 1))];
const isFullRange = fromIndex === 1 && toIndex === n;
const countInRange = days
.slice(fromIndex - 1, toIndex)
.reduce((sum, day) => sum + day.count, 0);
const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100);
const fromPct = pct(fromIndex);
const toPct = pct(toIndex);
const indexAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect || n <= 1) return 1;
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
};
const makeHandlers = (isFrom: boolean) => ({
onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.stopPropagation();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
},
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
const idx = indexAt(e.clientX);
if (isFrom) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
},
onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
},
});
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
const idx = indexAt(e.clientX);
if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) {
onFromChange(Math.max(1, Math.min(idx, toIndex)));
} else {
onToChange(Math.min(n, Math.max(idx, fromIndex)));
}
};
return (
<div className="map-pass-selector">
<div className="map-pass-selector__header">
<span className="map-pass-selector__title">Сутки</span>
<span className="map-pass-selector__nums">
{fromDay.label === toDay.label ? fromDay.label : `${fromDay.label}${toDay.label}`}
</span>
<span className="map-pass-selector__times">слотов {countInRange}</span>
{!isFullRange && (
<button
type="button"
className="map-pass-selector__reset"
title="Все сутки"
onClick={() => { onFromChange(1); onToChange(n); }}
>
Все
</button>
)}
</div>
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
<div className="map-pass-selector__track" ref={trackRef}>
<div
className="map-pass-selector__fill"
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
/>
{days.map((day) => (
<div
key={day.index}
className={`map-pass-selector__tick${day.index >= fromIndex && day.index <= toIndex ? " is-active" : ""}`}
style={{ left: `${pct(day.index)}%` }}
/>
))}
<div
className="map-pass-selector__thumb"
style={{ left: `${fromPct}%` }}
{...makeHandlers(true)}
/>
<div
className="map-pass-selector__thumb"
style={{ left: `${toPct}%` }}
{...makeHandlers(false)}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
import type { SlotDTO } from "../../model/slotTypes";
import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
/** Один слот в меню выбора: id полигона на карте, сам слот и признак брони. */
export type SlotPick = { id: string; slot: SlotDTO; booked: boolean };
type SlotPickMenuProps = {
picks: SlotPick[];
activeId?: string;
onPick: (id: string) => void;
onClose: () => void;
};
/**
* Меню выбора слота при плотном наложении: клик по карте, под которым лежит несколько слотов,
* раскрывает список (КА · слот · виток/ветвь · время), выбор подсвечивает строку в таблице.
*/
export function SlotPickMenu({ picks, activeId, onPick, onClose }: SlotPickMenuProps) {
return (
<aside className="tgu-slots-pick">
<div className="tgu-slots-pick__head">
<span>Слоты под курсором · {picks.length}</span>
<button type="button" className="tgu-icon-button" onClick={onClose} title="Закрыть">
×
</button>
</div>
<div className="tgu-slots-pick__list">
{picks.map(({ id, slot, booked }) => (
<button
key={id}
type="button"
className={`tgu-slots-pick__item${id === activeId ? " is-active" : ""}`}
onClick={() => onPick(id)}
>
<span className="tgu-slots-pick__title">
КА {slot.satelliteId} · слот {slot.slotNumber}
{booked && <span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>}
</span>
<span className="tgu-slots-pick__meta">
виток {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} ·{" "}
{formatLocalDateTime(parseLocal(slot.tn))}
</span>
</button>
))}
</div>
</aside>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { chunk, selectionAfterCheck } from "./slotSelection";
const keyAt = (i: number) => `k${i}`;
describe("selectionAfterCheck", () => {
it("добавляет ключ при обычном клике по невыбранной строке", () => {
const next = selectionAfterCheck(new Set(), keyAt, 2, false, null);
expect([...next]).toEqual(["k2"]);
});
it("снимает ключ при обычном клике по выбранной строке", () => {
const next = selectionAfterCheck(new Set(["k2"]), keyAt, 2, false, 5);
expect(next.has("k2")).toBe(false);
});
it("Shift выбирает диапазон от якоря до индекса (целевая была не выбрана)", () => {
const next = selectionAfterCheck(new Set(), keyAt, 4, true, 1);
expect([...next].sort()).toEqual(["k1", "k2", "k3", "k4"]);
});
it("Shift снимает диапазон, если целевая строка уже выбрана", () => {
const current = new Set(["k1", "k2", "k3", "k4", "k9"]);
const next = selectionAfterCheck(current, keyAt, 4, true, 1);
expect([...next]).toEqual(["k9"]);
});
it("Shift работает при обратном порядке (якорь больше индекса)", () => {
const next = selectionAfterCheck(new Set(), keyAt, 1, true, 4);
expect([...next].sort()).toEqual(["k1", "k2", "k3", "k4"]);
});
it("Shift без якоря ведёт себя как обычное переключение", () => {
const next = selectionAfterCheck(new Set(), keyAt, 3, true, null);
expect([...next]).toEqual(["k3"]);
});
it("не мутирует исходное множество", () => {
const current = new Set(["k2"]);
selectionAfterCheck(current, keyAt, 5, false, null);
expect([...current]).toEqual(["k2"]);
});
});
describe("chunk", () => {
it("бьёт массив на части заданного размера, последняя — остаток", () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it("размер >= длины — один чанк", () => {
expect(chunk([1, 2], 10)).toEqual([[1, 2]]);
});
it("пустой массив — нет чанков", () => {
expect(chunk([], 3)).toEqual([]);
});
it("size<=0 — единственный чанк со всем массивом", () => {
expect(chunk([1, 2, 3], 0)).toEqual([[1, 2, 3]]);
});
});
@@ -0,0 +1,52 @@
// Чистая логика выбора слотов и разбиения на чанки — выделена из TguSlotsTab,
// чтобы покрыть юнит-тестами без рендера компонента.
/**
* Новое множество выбора после клика по строке.
* Обычный клик переключает один ключ. Shift+клик при заданном якоре выбирает (или
* снимает) непрерывный диапазон от якоря до текущего индекса; направление задаётся новым
* состоянием текущей строки (если она была не выбрана диапазон выбираем, иначе снимаем).
*
* @param current Текущее множество выбранных ключей.
* @param keyAt Доступ к ключу слота по индексу в видимом (отфильтрованном) списке.
* @param index Индекс строки, по которой кликнули.
* @param shiftKey Был ли зажат Shift.
* @param anchorIndex Индекс предыдущего клика (якорь) либо null.
*/
export function selectionAfterCheck(
current: ReadonlySet<string>,
keyAt: (index: number) => string,
index: number,
shiftKey: boolean,
anchorIndex: number | null,
): Set<string> {
const next = new Set(current)
const targetKey = keyAt(index)
if (shiftKey && anchorIndex != null) {
const lo = Math.min(anchorIndex, index)
const hi = Math.max(anchorIndex, index)
const select = !current.has(targetKey)
for (let i = lo; i <= hi; i++) {
const k = keyAt(i)
if (select) next.add(k)
else next.delete(k)
}
} else if (next.has(targetKey)) {
next.delete(targetKey)
} else {
next.add(targetKey)
}
return next
}
/**
* Разбивает массив на чанки заданного размера (для чанкованной брони крупных наборов).
* @param items Исходный массив.
* @param size Размер чанка; <= 0 один чанк со всем массивом.
*/
export function chunk<T>(items: readonly T[], size: number): T[][] {
if (size <= 0) return items.length ? [items.slice()] : []
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
@@ -0,0 +1,63 @@
// Типы слотов покрытия и бронирования. Зеркалят DTO из pcp-types-lib
// (space.nstart.pcp.pcp_types_lib.dto.requests.slots), которые отдаёт slots-service.
/** Признак ветви витка (восходящая/нисходящая). */
export type RevolutionSign = "ASC" | "DESC";
/**
* Стратегия автоформирования покрытия (slots-service: SlotCoverageStrategy).
* GREEDY жадный подбор; CONTINUOUS непрерывное покрытие. Не задано все слоты-кандидаты.
*/
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
/** Статус слота из расчёта покрытия. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string;
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
export type SlotDTO = {
cycle: number;
satelliteId: number;
tn: string;
tk: string;
roll: number;
/** Контур полосы слота в WKT (POLYGON ...) — рисуем на 2D-карте. */
contour: string;
revolution: number;
revolutionSign: RevolutionSign;
slotNumber: number;
state: SlotStatus;
latitude: number;
longitude: number;
};
/** Забронированный слот (slots-service: BookedSlotDTO) — ответ на POST booking/request. */
export type BookedSlotDTO = {
bookedSlotId: number;
satelliteId: number;
slotNum: number;
cycle: number;
};
/** Идентификатор слота для бронирования (slots-service: SlotBookingRequestDTO). */
export type SlotBookingRequest = {
satelliteId: number;
slotNum: number;
cycle: number;
};
/** Параметры расчёта слотов покрытия заявки (GET /api/slots/request-cover). */
export type RequestSlotsParams = {
requestId: string;
timeStart: string;
timeStop: string;
/** Ветвь витка; не задано — обе ветви. */
revSign?: RevolutionSign;
/** true — считать ещё и площадь покрытия (дольше); false — только слоты-кандидаты. */
cov?: boolean;
/** Ограничение по КА; пусто — все доступные. */
satellites?: number[];
/** Угол Солнца, град (для оптики); не задано — без ограничения. */
sun?: number;
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
coverageStrategy?: SlotCoverageStrategy;
};
@@ -22,7 +22,14 @@ export type TimelineRange = {
toMs: number; toMs: number;
}; };
export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation"; export type TguActiveTab =
| "timeline"
| "map"
| "complex"
| "editor"
| "requests"
| "slots"
| "constellation";
export type TimelineRow = { export type TimelineRow = {
spacecraftId: string; spacecraftId: string;
@@ -1159,6 +1159,10 @@
.tgu-editor-pick__swatch--drop { background: #ffcf3f; } .tgu-editor-pick__swatch--drop { background: #ffcf3f; }
.tgu-editor-pick__swatch--station { background: #d5e2e2; } .tgu-editor-pick__swatch--station { background: #d5e2e2; }
.tgu-editor-pick__swatch--swath { background: #6fb7ff; } .tgu-editor-pick__swatch--swath { background: #6fb7ff; }
.tgu-editor-pick__swatch--complexPlan { background: #c080ff; }
.tgu-editor-pick__swatch--heatmap { background: #f5803e; }
.tgu-editor-pick__swatch--tracks { background: #8d99a6; }
.tgu-editor-pick__swatch--spacecraftMarkers { background: #d5e2e2; }
.tgu-editor-pick__title { .tgu-editor-pick__title {
font-size: 0.74rem; font-size: 0.74rem;
@@ -23,6 +23,14 @@
overflow-x: auto; overflow-x: auto;
} }
.tgu-toolbar__row--range {
min-height: 0;
padding-top: 0.55rem;
padding-bottom: 0.6rem;
gap: 0.5rem;
border-top: 1px solid var(--line);
}
.tgu-toolbar__brand { .tgu-toolbar__brand {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -733,6 +741,22 @@
color: var(--accent-strong); color: var(--accent-strong);
} }
/* Чекбокс-тумблер в тулбаре карты (напр. тепловая карта на вкладке «Комплексный план»). */
.tgu-checkbox {
display: inline-flex;
gap: 0.4rem;
align-items: center;
color: var(--text-muted);
font-size: 0.8rem;
white-space: nowrap;
cursor: pointer;
}
.tgu-checkbox input {
accent-color: var(--accent);
cursor: pointer;
}
.tgu-map-content { .tgu-map-content {
display: grid; display: grid;
grid-template-columns: 19rem minmax(0, 1fr); grid-template-columns: 19rem minmax(0, 1fr);
@@ -883,6 +907,255 @@
border-radius: 999px; border-radius: 999px;
} }
/* Тело вкладки «Комплексный план»: карта + правый инспектор объекта (как в редакторе). */
.tgu-complex-body {
display: grid;
min-width: 0;
min-height: 0;
}
/* Вкладка «Слоты»: слева список слотов, справа карта. */
.tgu-slots-body {
display: grid;
grid-template-columns: minmax(22rem, 32rem) minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.tgu-slots-list {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: var(--bg-panel);
}
/* Прокручиваемая область таблицы слотов — scrollElement виртуализатора. */
.tgu-slots-scroll {
min-height: 0;
flex: 1;
overflow: auto;
}
/* Панель выбора над таблицей: счётчик охвата + действия (все сутки / снять). */
.tgu-slots-selbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
font-size: 0.78rem;
}
.tgu-slots-selbar__count {
color: var(--text);
}
.tgu-slots-selbar__sub {
color: var(--text-dim);
}
.tgu-slots-selbar__actions {
display: flex;
gap: 0.35rem;
}
.tgu-slots-empty {
padding: 1rem;
color: var(--text-dim);
font-size: 0.82rem;
}
/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */
.tgu-slots-request {
align-items: center;
}
.tgu-slots-request__name {
min-height: 2rem;
display: flex;
align-items: center;
max-width: 18rem;
padding: 0.38rem 0.55rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #101214;
color: var(--text);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Левая панель в режиме «заявка не выбрана»: фильтр интервала заявок + подсказка. */
.tgu-slots-requests-panel {
display: grid;
gap: 0.6rem;
padding: 0.9rem;
}
/* Кнопка «Сброс» — справа над картой (поверх неё). */
.tgu-slots-reset {
position: absolute;
top: 0.55rem;
right: 0.55rem;
z-index: 21;
background: rgba(23, 26, 28, 0.93);
backdrop-filter: blur(6px);
}
/* Меню выбора слота при плотном наложении — плавающий список справа над картой (под «Сбросом»). */
.tgu-slots-pick {
position: absolute;
top: 3rem;
right: 0.55rem;
z-index: 24;
width: 22rem;
max-width: calc(100% - 1.1rem);
max-height: calc(100% - 3.6rem);
display: flex;
flex-direction: column;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(23, 26, 28, 0.96);
backdrop-filter: blur(6px);
overflow: hidden;
}
.tgu-slots-pick__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border-bottom: 1px solid var(--line);
font-size: 0.78rem;
font-weight: 600;
color: var(--text);
}
.tgu-slots-pick__list {
overflow: auto;
min-height: 0;
}
.tgu-slots-pick__item {
display: grid;
gap: 0.15rem;
width: 100%;
padding: 0.45rem 0.65rem;
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
text-align: left;
cursor: pointer;
color: var(--text);
}
.tgu-slots-pick__item:hover {
background: rgba(104, 195, 189, 0.1);
}
.tgu-slots-pick__item.is-active {
background: rgba(104, 195, 189, 0.22);
box-shadow: inset 3px 0 0 var(--accent);
}
.tgu-slots-pick__title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 600;
}
.tgu-slots-pick__meta {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--text-dim);
}
/* Инспектор заявки — плавающая панель справа над картой (до выбора заявки). */
.tgu-slots-inspector {
position: absolute;
top: 0.55rem;
right: 0.55rem;
z-index: 25;
width: 23rem;
max-width: calc(100% - 1.1rem);
max-height: calc(100% - 1.1rem);
overflow: auto;
padding: 0.9rem;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(23, 26, 28, 0.96);
backdrop-filter: blur(6px);
}
.tgu-slots-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.tgu-slots-table th,
.tgu-slots-table td {
padding: 0.35rem 0.5rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--line);
}
.tgu-slots-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-panel);
color: var(--text-dim);
font-weight: 600;
}
.tgu-slots-table tbody tr {
cursor: pointer;
}
.tgu-slots-table tbody tr:hover {
background: rgba(104, 195, 189, 0.08);
}
.tgu-slots-table tbody tr.is-active {
background: rgba(104, 195, 189, 0.3);
box-shadow: inset 3px 0 0 var(--accent);
}
.tgu-slots-table tbody tr.is-active td {
color: var(--text);
font-weight: 600;
}
.tgu-slots-table tbody tr.is-active:hover {
background: rgba(104, 195, 189, 0.36);
}
.tgu-slots-badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: #101214;
border: 1px solid var(--line);
color: var(--text-muted);
font-size: 0.72rem;
}
.tgu-slots-badge--booked {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
.tgu-map-stage { .tgu-map-stage {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -935,6 +1208,14 @@
stroke-width: 2; stroke-width: 2;
} }
/* Отобранные под бронь слоты — постоянная зелёная подсветка (отличается от оранжевого выбора). */
.tgu-2d-map__overlay polygon.is-marked {
fill: rgba(58, 209, 122, 0.22);
stroke: #3ad17a;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__overlay polyline.is-selected { .tgu-2d-map__overlay polyline.is-selected {
stroke: #f0ad2e; stroke: #f0ad2e;
stroke-width: 7; stroke-width: 7;
@@ -1029,6 +1310,75 @@
background: var(--bg-panel); background: var(--bg-panel);
color: var(--text); color: var(--text);
font-weight: 700; font-weight: 700;
cursor: pointer;
}
.tgu-2d-map__zoom button:hover {
border-color: rgba(104, 195, 189, 0.5);
}
/* Кнопка-линейка: активный режим подсвечиваем как тумблер. */
.tgu-2d-map__zoom-ruler.is-active {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.18);
color: var(--accent-strong);
}
/* Замер линейки на оверлее: ломаная, вершины и подписи расстояния. */
.tgu-2d-map__ruler-line {
stroke: #f0ad2e;
stroke-width: 2;
stroke-dasharray: 6 4;
}
.tgu-2d-map__ruler-vertex {
fill: #f0ad2e;
stroke: #1a1305;
stroke-width: 1;
}
.tgu-2d-map__ruler-label {
fill: #ffe7b5;
font-size: 11px;
font-weight: 600;
paint-order: stroke;
stroke: rgba(10, 12, 14, 0.85);
stroke-width: 3;
}
/* Бейдж итоговой длины — в правом нижнем углу, над кнопками управления. */
.tgu-2d-map__ruler-total {
position: absolute;
right: 0.75rem;
bottom: 9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.55rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.9);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
.tgu-2d-map__ruler-total-label {
color: var(--text-dim);
}
.tgu-2d-map__ruler-total-value {
font-weight: 700;
}
.tgu-2d-map__ruler-reset {
border: 1px solid var(--line);
border-radius: 5px;
padding: 0.1rem 0.45rem;
background: #101214;
color: var(--text-muted);
font-size: 0.72rem;
cursor: pointer;
} }
.tgu-map-overlay { .tgu-map-overlay {
@@ -1046,6 +1396,53 @@
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
/* Кнопка отмены внутри оверлея расчёта/автоподбора — на отдельной строке под текстом. */
.tgu-map-overlay button {
display: block;
margin-top: 0.5rem;
}
/* Подсказка о перегрузе карты слотами (карту не блокируем). */
.tgu-slots-map-hint {
position: absolute;
z-index: 5;
left: 50%;
bottom: 0.85rem;
transform: translateX(-50%);
max-width: 90%;
padding: 0.4rem 0.7rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
/* Баннер подтверждения крупной брони. */
.tgu-slots-confirm {
position: absolute;
z-index: 6;
top: 0.85rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--accent);
border-radius: 8px;
background: rgba(17, 19, 21, 0.94);
color: var(--text);
font-size: 0.82rem;
backdrop-filter: blur(6px);
}
.tgu-slots-confirm__actions {
display: flex;
gap: 0.4rem;
}
.tgu-map-errors { .tgu-map-errors {
display: grid; display: grid;
gap: 0.4rem; gap: 0.4rem;
+4 -32
View File
@@ -21,40 +21,12 @@ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5174, port: 5174,
// ТГУ ходит в несколько бэкендов через префиксы. В проде эти маршруты // Всё /api/* уходит на gateway (Spring Cloud Gateway, dev :7080). Gateway сам
// повторяются в nginx.conf (зеркало) либо на gateway/ingress. // срезает префиксы pcp-* (StripPrefix) и выбирает бэкенд — ровно то, что раньше
// делали правила-зеркала здесь. В проде то же делает nginx.conf.
proxy: { proxy: {
'/api/pcp-request': {
target: 'http://localhost:7005',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-request/, ''),
},
'/api/pcp-ballistics': {
target: 'http://localhost:7003',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-ballistics/, ''),
},
'/api/pcp-mission': {
target: 'http://localhost:7010',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-mission/, ''),
},
'/api/pcp-tgu': {
target: 'http://localhost:7011',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-tgu/, ''),
},
'/api/pcp-complex': {
target: 'http://localhost:7002',
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api\/pcp-complex/, ''),
},
'/api/stations': {
target: 'http://localhost:7009',
changeOrigin: true,
},
'/api': { '/api': {
target: 'http://localhost:7008', target: 'http://localhost:7080',
changeOrigin: true, changeOrigin: true,
}, },
}, },
@@ -1,62 +0,0 @@
package space.nstart.pcp.slots_service.configuration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import org.springframework.web.filter.CorsFilter
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
// Внутренний ops-инструмент: UI открывают с разных хостов/IP (localhost,
// LAN-адрес рабочей станции, прод-сборка). Любой явный список origin'ов рано
// или поздно не совпадает с фактическим адресом UI → POST/PUT/DELETE отбивается
// как «Invalid CORS request» (same-origin GET проходит, т.к. идёт без Origin).
// Поэтому разрешаем любой origin. allowedOriginPatterns("*") совместим с
// allowCredentials=true (в отличие от allowedOrigins("*")).
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600)
}
// Альтернативный вариант через CorsFilter
@Bean
fun corsFilter(): CorsFilter {
val source = UrlBasedCorsConfigurationSource()
val config = CorsConfiguration().apply {
allowCredentials = true
// Любой origin — см. комментарий в addCorsMappings.
allowedOriginPatterns = listOf("*")
// Разрешенные заголовки
allowedHeaders = listOf(
"Origin",
"Content-Type",
"Accept",
"Authorization",
"X-Requested-With",
"Access-Control-Request-Method",
"Access-Control-Request-Headers"
)
// Разрешенные методы
allowedMethods = listOf(
"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", "HEAD"
)
// Заголовки, доступные клиенту
exposedHeaders = listOf(
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials"
)
maxAge = 3600L
}
source.registerCorsConfiguration("/**", config)
return CorsFilter(source)
}
}
+4 -3
View File
@@ -24,12 +24,13 @@ include(":services:pcp-coverage-scheme-service")
include(":services:pcp-dynamic-plan-service") include(":services:pcp-dynamic-plan-service")
include(":services:spring-cloud-config-server") include(":services:spring-cloud-config-server")
include(":services:pcp-srpring-boot-admin-server") include(":services:pcp-srpring-boot-admin-server")
include(":services:pcp-gateway-service")
pluginManagement { pluginManagement {
repositories { repositories {
maven { // repo.nstart.cloud/maven-proxy сейчас отдаёт HTML вместо артефактов —
url = uri("https://repo.nstart.cloud/repository/maven-proxy/") // плагины резолвим напрямую с публичных репозиториев.
} // maven { url = uri("https://repo.nstart.cloud/repository/maven-proxy/") }
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }