Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 внутри сервисов при старте приложений.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Executable
+199
@@ -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
|
||||||
Executable
+242
@@ -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
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -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_database_if_missing "$db_name"
|
create_external_database_if_missing "$db_name"
|
||||||
done
|
done
|
||||||
|
else
|
||||||
|
compose up -d postgres
|
||||||
|
wait_for_postgres
|
||||||
|
|
||||||
|
for db_name in "${databases[@]}"; do
|
||||||
|
create_database_if_missing "$db_name"
|
||||||
|
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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -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
@@ -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 "$@"
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Executable
+538
@@ -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** (пакеты/опечатки) — отдельными задачами из-за широкого охвата.
|
||||||
@@ -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:50–12: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:50–12: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).
|
||||||
|
Если такая обогащённая поверхность понадобится новому фронту — она должна переехать в
|
||||||
|
источник или во фронт, а не остаться «висеть» в умирающем сервисе. Сейчас новый фронт её
|
||||||
|
не использует.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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: ""
|
||||||
@@ -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")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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)
|
||||||
|
}
|
||||||
+51
@@ -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}
|
||||||
+4
-1
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
+18
-1
@@ -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"]
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[]>
|
||||||
|
}
|
||||||
@@ -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,25 +327,56 @@ 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>
|
||||||
|
|
||||||
<section className="tgu-map-stage">
|
<div
|
||||||
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
|
className="tgu-complex-body"
|
||||||
<Tgu2DMapView
|
style={{ gridTemplateColumns: mapHits.length > 0 ? "minmax(0, 1fr) 22rem" : "minmax(0, 1fr) 0" }}
|
||||||
scene={scene}
|
>
|
||||||
layers={layers}
|
<section className="tgu-map-stage">
|
||||||
redrawToken={modes.length + requestPolygons.length}
|
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
|
||||||
onObjectSelect={() => undefined}
|
<Tgu2DMapView
|
||||||
/>
|
scene={scene}
|
||||||
</section>
|
layers={layers}
|
||||||
|
redrawToken={modes.length + requestPolygons.length + heatmapPolygons.length}
|
||||||
|
enableObjectChooser
|
||||||
|
onObjectsAtPoint={handleObjectsAtPoint}
|
||||||
|
onObjectSelect={handleObjectSelect}
|
||||||
|
selectedObjectId={activeHitId}
|
||||||
|
/>
|
||||||
|
</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 && (
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
try {
|
||||||
createdImplicitlyRef.current = true;
|
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
||||||
setDraftPlanId(created.draftPlanId);
|
createdImplicitlyRef.current = true;
|
||||||
return created.draftPlanId;
|
setDraftPlanId(created.draftPlanId);
|
||||||
}, [draftPlanId, selectedPlan]);
|
return created.draftPlanId;
|
||||||
|
} 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}
|
||||||
)}
|
/>
|
||||||
{stationPanel != null && (
|
</aside>
|
||||||
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Фоновый слой тепловой карты приоритетов: заливка ячеек сетки заданий цветом по важности
|
||||||
|
* (cool→hot). Важность нормируется к максимуму набора, поэтому шкала подстраивается под данные.
|
||||||
|
* Рисуется ПОД контурами плана/заявок, без обводки и 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,29 +107,33 @@ export function TguToolbar({
|
|||||||
Группировка
|
Группировка
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="tgu-field">
|
|
||||||
<span>С</span>
|
|
||||||
<DateTime24Field value={fromValue} onChange={onFromChange} />
|
|
||||||
</label>
|
|
||||||
<label className="tgu-field">
|
|
||||||
<span>По</span>
|
|
||||||
<DateTime24Field value={toValue} onChange={onToChange} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
|
|
||||||
Показать
|
|
||||||
</button>
|
|
||||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
|
|
||||||
24ч
|
|
||||||
</button>
|
|
||||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
|
|
||||||
3 суток
|
|
||||||
</button>
|
|
||||||
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
|
|
||||||
7 суток
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showRange && (
|
||||||
|
<div className="tgu-toolbar__row tgu-toolbar__row--range">
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>С</span>
|
||||||
|
<DateTime24Field value={fromValue} onChange={onFromChange} />
|
||||||
|
</label>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>По</span>
|
||||||
|
<DateTime24Field value={toValue} onChange={onToChange} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
|
||||||
|
Показать
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
|
||||||
|
24ч
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
|
||||||
|
3 суток
|
||||||
|
</button>
|
||||||
|
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
|
||||||
|
7 суток
|
||||||
|
</button>
|
||||||
|
</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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
-62
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user