From ab3a787da55fa6a48e964e80e5aa67389efb4867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 13:34:16 +0300 Subject: [PATCH 01/11] =?UTF-8?q?feat(gateway):=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=20pcp-gateway-service=20+=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - новый Spring Cloud Gateway (WebFlux): доменные маршруты /api/pcp-/** со StripPrefix=2, /api/stations без среза пути, catch-all /api/** → ui-service; опциональная JWT-валидация на edge (gateway.security.jwt-enabled, по умолчанию off) - config-repo: pcp-gateway-service.yaml (маршруты) + порт/адрес gateway в реестрах application-{local,docker-local,dev} - build: репозитории переведены на mavenCentral (nstart-proxy закомментирован), включены mavenLocal/gradlePluginPortal; settings: include модуля - pcp-tgu-ui-service: vite-proxy и nginx.conf свёрнуты на единый /api → gateway (вместо набора per-service правил) - deploy: сервис gateway в docker-compose; helm-чарт pcp-gateway-service - CI: .gitlab/ci/pcp-gateway-service.yml + проводка в .gitlab-ci.yml --- .gitlab-ci.yml | 7 + .gitlab/ci/pcp-gateway-service.yml | 191 ++++++++++++++++++ build.gradle.kts | 12 +- config-repo/application-dev.yaml | 2 + config-repo/application-docker-local.yaml | 2 + config-repo/application-local.yaml | 2 + config-repo/pcp-gateway-service.yaml | 88 ++++++++ deploy/local-bundle/docker-compose.yml | 8 + helm/pcp-gateway-service/Chart.yaml | 6 + .../templates/_helpers.tpl | 18 ++ .../templates/deployment.yaml | 68 +++++++ .../templates/ingress.yaml | 31 +++ .../templates/service.yaml | 16 ++ helm/pcp-gateway-service/values-dev.yaml | 10 + helm/pcp-gateway-service/values-master.yaml | 10 + helm/pcp-gateway-service/values.yaml | 36 ++++ services/pcp-gateway-service/Dockerfile | 9 + services/pcp-gateway-service/build.gradle.kts | 50 +++++ .../pcp_gateway_service/GatewayApplication.kt | 16 ++ .../config/SecurityConfig.kt | 51 +++++ .../src/main/resources/application.yml | 13 ++ services/pcp-tgu-ui-service/nginx.conf | 64 +----- services/pcp-tgu-ui-service/vite.config.ts | 36 +--- settings.gradle.kts | 1 + 24 files changed, 652 insertions(+), 95 deletions(-) create mode 100644 .gitlab/ci/pcp-gateway-service.yml create mode 100644 config-repo/pcp-gateway-service.yaml create mode 100644 helm/pcp-gateway-service/Chart.yaml create mode 100644 helm/pcp-gateway-service/templates/_helpers.tpl create mode 100644 helm/pcp-gateway-service/templates/deployment.yaml create mode 100644 helm/pcp-gateway-service/templates/ingress.yaml create mode 100644 helm/pcp-gateway-service/templates/service.yaml create mode 100644 helm/pcp-gateway-service/values-dev.yaml create mode 100644 helm/pcp-gateway-service/values-master.yaml create mode 100644 helm/pcp-gateway-service/values.yaml create mode 100644 services/pcp-gateway-service/Dockerfile create mode 100644 services/pcp-gateway-service/build.gradle.kts create mode 100644 services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/GatewayApplication.kt create mode 100644 services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/config/SecurityConfig.kt create mode 100644 services/pcp-gateway-service/src/main/resources/application.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a12e74f..b2bd229 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,6 +20,7 @@ workflow: - services/pcp-tgu-service/**/* - services/pcp-tgu-ui-service/**/* - services/pcp-dynamic-plan-service/**/* + - services/pcp-gateway-service/**/* - libs/pcp-types-lib/**/* - libs/ballistics-lib/**/* - build.gradle.kts @@ -43,6 +44,7 @@ workflow: - helm/pcp-tgu-service/**/* - helm/pcp-tgu-ui-service/**/* - helm/pcp-dynamic-plan-service/**/* + - helm/pcp-gateway-service/**/* - if: '$CI_PIPELINE_SOURCE == "push"' changes: - .gitlab-ci.yml @@ -64,6 +66,7 @@ workflow: - services/pcp-tgu-service/**/* - services/pcp-tgu-ui-service/**/* - services/pcp-dynamic-plan-service/**/* + - services/pcp-gateway-service/**/* - libs/pcp-types-lib/**/* - libs/ballistics-lib/**/* - build.gradle.kts @@ -87,6 +90,7 @@ workflow: - helm/pcp-tgu-service/**/* - helm/pcp-tgu-ui-service/**/* - helm/pcp-dynamic-plan-service/**/* + - helm/pcp-gateway-service/**/* - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "master"' changes: - services/pcp-ballistics-service/**/* @@ -104,6 +108,7 @@ workflow: - services/pcp-tgu-service/**/* - services/pcp-tgu-ui-service/**/* - services/pcp-dynamic-plan-service/**/* + - services/pcp-gateway-service/**/* - libs/pcp-types-lib/**/* - libs/ballistics-lib/**/* - build.gradle.kts @@ -127,6 +132,7 @@ workflow: - helm/pcp-tgu-service/**/* - helm/pcp-tgu-ui-service/**/* - helm/pcp-dynamic-plan-service/**/* + - helm/pcp-gateway-service/**/* - when: never default: @@ -168,3 +174,4 @@ include: - local: .gitlab/ci/pcp-tgu-service.yml - local: .gitlab/ci/pcp-tgu-ui-service.yml - local: .gitlab/ci/pcp-dynamic-plan-service.yml + - local: .gitlab/ci/pcp-gateway-service.yml diff --git a/.gitlab/ci/pcp-gateway-service.yml b/.gitlab/ci/pcp-gateway-service.yml new file mode 100644 index 0000000..cfbb6fb --- /dev/null +++ b/.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 diff --git a/build.gradle.kts b/build.gradle.kts index 333cb2d..2ba8cb3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,18 +16,18 @@ allprojects { group = "space.nstart.pcp" ext.set("kotlinVersion", "2.2.20") repositories { - maven { - url = uri("https://repo.nstart.cloud/repository/maven-proxy/") - } +// maven { +// url = uri("https://repo.nstart.cloud/repository/maven-proxy/") +// } mavenCentral { content { includeGroup("org.jacoco") includeGroup("org.ow2.asm") } } -// mavenCentral() -// mavenLocal() -// gradlePluginPortal() + mavenCentral() + mavenLocal() + gradlePluginPortal() maven { url = uri("https://jitpack.io") } exclusiveContent { diff --git a/config-repo/application-dev.yaml b/config-repo/application-dev.yaml index 947eea6..038880a 100644 --- a/config-repo/application-dev.yaml +++ b/config-repo/application-dev.yaml @@ -29,6 +29,7 @@ pcp: coverage-scheme: 8080 satellite-catalog: 8080 dynamic-plan: 8080 + gateway: 8080 services: config: http://${pcp.network.host}:${pcp.ports.config} @@ -47,6 +48,7 @@ pcp: coverage-scheme: http://pcp-coverage-scheme-service:${pcp.ports.coverage-scheme} satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan} + gateway: http://pcp-gateway-service:${pcp.ports.gateway} spring: boot: diff --git a/config-repo/application-docker-local.yaml b/config-repo/application-docker-local.yaml index 7b479db..d52924f 100644 --- a/config-repo/application-docker-local.yaml +++ b/config-repo/application-docker-local.yaml @@ -27,6 +27,7 @@ pcp: coverage-scheme: 7012 satellite-catalog: 7013 dynamic-plan: 7014 + gateway: 7080 services: 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} satellite-catalog: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} dynamic-plan: http://pcp-dynamic-plan-service:${pcp.ports.dynamic-plan} + gateway: http://pcp-gateway-service:${pcp.ports.gateway} spring: kafka: diff --git a/config-repo/application-local.yaml b/config-repo/application-local.yaml index ccd2cc3..1954f96 100644 --- a/config-repo/application-local.yaml +++ b/config-repo/application-local.yaml @@ -31,6 +31,7 @@ pcp: coverage-scheme: 7012 satellite-catalog: 7013 dynamic-plan: 7014 + gateway: 7080 services: config: http://${pcp.network.host}:${pcp.ports.config} @@ -49,6 +50,7 @@ pcp: coverage-scheme: http://${pcp.network.host}:${pcp.ports.coverage-scheme} satellite-catalog: http://${pcp.network.host}:${pcp.ports.satellite-catalog} dynamic-plan: http://${pcp.network.host}:${pcp.ports.dynamic-plan} + gateway: http://${pcp.network.host}:${pcp.ports.gateway} spring: boot: diff --git a/config-repo/pcp-gateway-service.yaml b/config-repo/pcp-gateway-service.yaml new file mode 100644 index 0000000..57ec2d5 --- /dev/null +++ b/config-repo/pcp-gateway-service.yaml @@ -0,0 +1,88 @@ +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: + routes: + # --- Доменные сервисы. Префикс /api/pcp- СРЕЗАЕТСЯ (StripPrefix=2 убирает + # 2 сегмента: api + pcp-), т.е. /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 + # --- Станции: путь СОХРАНЯЕМ (в vite было без rewrite) — без StripPrefix. + - id: stations + uri: ${pcp.services.stations} + predicates: + - Path=/api/stations/** + # --- Общий catch-all /api/** -> ui-service. Матчится ПОСЛЕДНИМ (order выше). + - id: ui-catch-all + uri: ${pcp.services.ui} + predicates: + - Path=/api/** + order: 1 + +# Валидация JWT на edge. По умолчанию ВЫКЛЮЧЕНА (anonymous, как сейчас работает фронт). +# Для закрытого контура: GATEWAY_JWT_ENABLED=true И задать issuer через env +# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=. +# 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} diff --git a/deploy/local-bundle/docker-compose.yml b/deploy/local-bundle/docker-compose.yml index a43c1d6..dd52411 100644 --- a/deploy/local-bundle/docker-compose.yml +++ b/deploy/local-bundle/docker-compose.yml @@ -182,6 +182,14 @@ services: ports: - "${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: postgres-data: kafka-data: diff --git a/helm/pcp-gateway-service/Chart.yaml b/helm/pcp-gateway-service/Chart.yaml new file mode 100644 index 0000000..4b01f97 --- /dev/null +++ b/helm/pcp-gateway-service/Chart.yaml @@ -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" diff --git a/helm/pcp-gateway-service/templates/_helpers.tpl b/helm/pcp-gateway-service/templates/_helpers.tpl new file mode 100644 index 0000000..614ddb6 --- /dev/null +++ b/helm/pcp-gateway-service/templates/_helpers.tpl @@ -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 -}} diff --git a/helm/pcp-gateway-service/templates/deployment.yaml b/helm/pcp-gateway-service/templates/deployment.yaml new file mode 100644 index 0000000..d380133 --- /dev/null +++ b/helm/pcp-gateway-service/templates/deployment.yaml @@ -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 }} diff --git a/helm/pcp-gateway-service/templates/ingress.yaml b/helm/pcp-gateway-service/templates/ingress.yaml new file mode 100644 index 0000000..03f1b55 --- /dev/null +++ b/helm/pcp-gateway-service/templates/ingress.yaml @@ -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 }} diff --git a/helm/pcp-gateway-service/templates/service.yaml b/helm/pcp-gateway-service/templates/service.yaml new file mode 100644 index 0000000..32633a6 --- /dev/null +++ b/helm/pcp-gateway-service/templates/service.yaml @@ -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 diff --git a/helm/pcp-gateway-service/values-dev.yaml b/helm/pcp-gateway-service/values-dev.yaml new file mode 100644 index 0000000..e2fadb8 --- /dev/null +++ b/helm/pcp-gateway-service/values-dev.yaml @@ -0,0 +1,10 @@ +app: + profile: dev + configLabel: dev + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-gateway-service.dev.k8s.264.nstart.cloud diff --git a/helm/pcp-gateway-service/values-master.yaml b/helm/pcp-gateway-service/values-master.yaml new file mode 100644 index 0000000..dee393f --- /dev/null +++ b/helm/pcp-gateway-service/values-master.yaml @@ -0,0 +1,10 @@ +app: + profile: master + configLabel: master + +service: + port: 8080 + +ingress: + enabled: true + host: pcp-gateway-service.master.k8s.264.nstart.cloud diff --git a/helm/pcp-gateway-service/values.yaml b/helm/pcp-gateway-service/values.yaml new file mode 100644 index 0000000..faf8c5c --- /dev/null +++ b/helm/pcp-gateway-service/values.yaml @@ -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: "" diff --git a/services/pcp-gateway-service/Dockerfile b/services/pcp-gateway-service/Dockerfile new file mode 100644 index 0000000..6fecd3c --- /dev/null +++ b/services/pcp-gateway-service/Dockerfile @@ -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"] diff --git a/services/pcp-gateway-service/build.gradle.kts b/services/pcp-gateway-service/build.gradle.kts new file mode 100644 index 0000000..7a75635 --- /dev/null +++ b/services/pcp-gateway-service/build.gradle.kts @@ -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 { + useJUnitPlatform() +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}") + } +} diff --git a/services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/GatewayApplication.kt b/services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/GatewayApplication.kt new file mode 100644 index 0000000..0dc5569 --- /dev/null +++ b/services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/GatewayApplication.kt @@ -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) { + runApplication(*args) +} diff --git a/services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/config/SecurityConfig.kt b/services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/config/SecurityConfig.kt new file mode 100644 index 0000000..295dba0 --- /dev/null +++ b/services/pcp-gateway-service/src/main/kotlin/space/nstart/pcp_gateway_service/config/SecurityConfig.kt @@ -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() +} diff --git a/services/pcp-gateway-service/src/main/resources/application.yml b/services/pcp-gateway-service/src/main/resources/application.yml new file mode 100644 index 0000000..fdb6af6 --- /dev/null +++ b/services/pcp-gateway-service/src/main/resources/application.yml @@ -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} diff --git a/services/pcp-tgu-ui-service/nginx.conf b/services/pcp-tgu-ui-service/nginx.conf index 78b61f1..45ea091 100644 --- a/services/pcp-tgu-ui-service/nginx.conf +++ b/services/pcp-tgu-ui-service/nginx.conf @@ -21,64 +21,14 @@ server { try_files $uri =404; } - # --- ТГУ: маршрутизация на бэкенды (зеркало server.proxy из vite.config.ts). - # СЕМАНТИКА (важно повторить точно, иначе бэкенды получат неверный путь): - # - префиксы pcp-* СРЕЗАЮТСЯ: trailing-slash в proxy_pass отбрасывает префикс - # location (как rewrite в vite). Пример: /api/pcp-tgu/api/plans → бэкенд /api/plans. - # - /api/stations путь СОХРАНЯЕТ (в vite без rewrite): proxy_pass без URI-части. - # - общий /api/ — catch-all (tgu-planning, current-plans, auth) на ui-service/gateway. - # Порядок важен: специфичные префиксы идут РАНЬШЕ общего /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. + # --- ТГУ: вся маршрутизация /api/* уходит на gateway (Spring Cloud Gateway). + # Gateway сам срезает префиксы pcp-* (StripPrefix) и выбирает бэкенд — + # см. config-repo/pcp-gateway-service.yaml. Здесь прозрачный reverse-proxy: + # proxy_pass БЕЗ URI-части → путь /api/... передаётся gateway без изменений + # (он разбирает его сам). Это заменяет прежний набор per-service location'ов. + # Адрес gateway в кластере — pcp-gateway-service:8080 (dev/master-профиль). 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 X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/services/pcp-tgu-ui-service/vite.config.ts b/services/pcp-tgu-ui-service/vite.config.ts index f03e87c..9665873 100644 --- a/services/pcp-tgu-ui-service/vite.config.ts +++ b/services/pcp-tgu-ui-service/vite.config.ts @@ -21,40 +21,12 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 5174, - // ТГУ ходит в несколько бэкендов через префиксы. В проде эти маршруты - // повторяются в nginx.conf (зеркало) либо на gateway/ingress. + // Всё /api/* уходит на gateway (Spring Cloud Gateway, dev :7080). Gateway сам + // срезает префиксы pcp-* (StripPrefix) и выбирает бэкенд — ровно то, что раньше + // делали правила-зеркала здесь. В проде то же делает nginx.conf. 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': { - target: 'http://localhost:7008', + target: 'http://localhost:7080', changeOrigin: true, }, }, diff --git a/settings.gradle.kts b/settings.gradle.kts index 4cbf6b7..8033846 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include(":services:pcp-coverage-scheme-service") include(":services:pcp-dynamic-plan-service") include(":services:spring-cloud-config-server") include(":services:pcp-srpring-boot-admin-server") +include(":services:pcp-gateway-service") pluginManagement { repositories { From 3c4ebcd7d322c6910bcd56da0eacf2d3ac2f14d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 14:04:29 +0300 Subject: [PATCH 02/11] =?UTF-8?q?refactor(cors):=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20CORS=20=D0=BD=D0=B0=20gateway,=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20per-service=20=D0=B8=D0=B7=20pcp-ui-servic?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORS-политика (allowedOriginPatterns:* + allowCredentials) переезжает в единый globalcors на pcp-gateway-service. После сворачивания фронта на gateway браузер ходит к бэкендам только через него (same-origin via Vite/nginx), поэтому per-service CORS в pcp-ui-service/WebConfig.kt стал мёртвым грузом. globalcors оставлен как страховка от cross-origin напрямую к gateway. --- config-repo/pcp-gateway-service.yaml | 13 ++++ .../slots_service/configuration/WebConfig.kt | 62 ------------------- 2 files changed, 13 insertions(+), 62 deletions(-) delete mode 100644 services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt diff --git a/config-repo/pcp-gateway-service.yaml b/config-repo/pcp-gateway-service.yaml index 57ec2d5..af1d5ac 100644 --- a/config-repo/pcp-gateway-service.yaml +++ b/config-repo/pcp-gateway-service.yaml @@ -10,6 +10,19 @@ spring: 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- СРЕЗАЕТСЯ (StripPrefix=2 убирает # 2 сегмента: api + pcp-), т.е. /api/pcp-tgu/api/plans -> бэкенд /api/plans. diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt deleted file mode 100644 index 11631c8..0000000 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/WebConfig.kt +++ /dev/null @@ -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) - } -} From 3765efb4fee97377ef8e25dadaf775e03be41cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 14:47:05 +0300 Subject: [PATCH 03/11] =?UTF-8?q?feat(ui):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20=D1=84=D1=80=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=20=D1=81=20pcp-ui-service=20=D0=BD=D0=B0=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8,=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20gateway=20catch-all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Запросы tguApi.ts (platforms/plans/decision/dismiss/restore/confirm-layin) и flight-line переведены с прокси /api/tgu-planning через умирающий pcp-ui-service на прямые вызовы pcp-tgu-service (/api/pcp-tgu, decision и confirm-layin — на тест-контроллер /test/tgu/*) и pcp-ballistics-service. flight-line перенесён в ballisticsApi.ts. editorApi.ts modes переведён с /api/current-plans на /api/pcp-mission. UX-перевод ошибок решения уже жил во фронте (errorMessage в tguApi.ts), серверная обвязка ui-service дублировала. Из config-repo/pcp-gateway-service.yaml снят маршрут ui-catch-all: не-матчнутый /api/** теперь отдаёт 404 на gateway, а не уходит в pcp-ui-service. Шаг 8 (вывод сервиса из эксплуатации: compose/helm/CI/реестр + удаление кода) отложен до проверки репойнта на живом стенде; сервис пока задеплоен без маршрутов. См. docs/план-перевода-с-pcp-ui-service.md и задачу #40 бэклога. --- config-repo/pcp-gateway-service.yaml | 10 +- docs/бэклог-pcp-tgu-ops-ui.md | 12 +- docs/план-перевода-с-pcp-ui-service.md | 116 ++++++++++++++++++ .../src/api/ballisticsApi.ts | 58 +++++++++ services/pcp-tgu-ui-service/src/api/tguApi.ts | 54 ++------ .../src/features/constellation/realData.ts | 4 +- .../src/features/tgu-editor/TguEditorTab.tsx | 3 +- .../src/features/tgu-editor/editorApi.ts | 9 +- 8 files changed, 209 insertions(+), 57 deletions(-) create mode 100644 docs/план-перевода-с-pcp-ui-service.md diff --git a/config-repo/pcp-gateway-service.yaml b/config-repo/pcp-gateway-service.yaml index af1d5ac..33c0611 100644 --- a/config-repo/pcp-gateway-service.yaml +++ b/config-repo/pcp-gateway-service.yaml @@ -62,12 +62,10 @@ spring: uri: ${pcp.services.stations} predicates: - Path=/api/stations/** - # --- Общий catch-all /api/** -> ui-service. Матчится ПОСЛЕДНИМ (order выше). - - id: ui-catch-all - uri: ${pcp.services.ui} - predicates: - - Path=/api/** - order: 1 + # 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 diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index 4bef3cf..9fcaf4f 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -13,7 +13,7 @@ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). -_Обновлено: 2026-06-08_ +_Обновлено: 2026-06-10_ --- @@ -43,6 +43,16 @@ _Обновлено: 2026-06-08_ - [ ] **#27** — Проверить сервис получения паспорта маршрута по отдельности. - [ ] **#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`) и удаление кода — **отложено** до проверки репойнта на живом стенде. Пока сервис задеплоен, но без маршрутов. + +### Слоты (вкладка «Заявка» и «Редактор») +- [ ] **#41** — Запрос слотового покрытия по заявке. На вкладке «Заявка» (и в «Редакторе») запрашивать у slots-service слотовое покрытие заявки — какие слоты КА покрывают AOI заявки на её интервале. Источник — slots-service (минуя умирающий `pcp-ui-service`, ср. #40). Уточнить контракт ответа (слоты + связь с ячейками/покрытием заявки) и где он отрисовывается. +- [ ] **#42** — Бронирование слотов по заявке. С вкладки «Заявка» (и/или «Редактор») бронировать выбранные слоты под заявку через slots-service. Связано с показом забронированных слотов (#3) и пересчётом комплексного плана при бронировании (#6). Уточнить: единичное/пакетное бронирование, идемпотентность, что возвращается после брони. +- [ ] **#43** — Уточнение статуса слота. На вкладке «Заявка» (и «Редактор») показывать/обновлять актуальный статус слота (свободен / забронирован / занят по заложенному плану). Учитывать, что слоты занимаются по `LAID_IN`, а не по `ACCEPT` (ср. #10). Источник статусов — slots-service; согласовать с #41/#42. + ### Баги - [ ] **#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 подтвердить у владельца каталога/баллистиков перед правкой._ diff --git a/docs/план-перевода-с-pcp-ui-service.md b/docs/план-перевода-с-pcp-ui-service.md new file mode 100644 index 0000000..6497b6c --- /dev/null +++ b/docs/план-перевода-с-pcp-ui-service.md @@ -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`. +Фронтовый `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). + Если такая обогащённая поверхность понадобится новому фронту — она должна переехать в + источник или во фронт, а не остаться «висеть» в умирающем сервисе. Сейчас новый фронт её + не использует. diff --git a/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts b/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts index 32cb524..e574081 100644 --- a/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts +++ b/services/pcp-tgu-ui-service/src/api/ballisticsApi.ts @@ -4,6 +4,64 @@ import { apiFetch } from './apiFetch' 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 { + 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 = { /** NORAD-идентификатор КА. */ noradId: number diff --git a/services/pcp-tgu-ui-service/src/api/tguApi.ts b/services/pcp-tgu-ui-service/src/api/tguApi.ts index 8579549..eec39b1 100644 --- a/services/pcp-tgu-ui-service/src/api/tguApi.ts +++ b/services/pcp-tgu-ui-service/src/api/tguApi.ts @@ -5,53 +5,21 @@ import { type TguPlanDecisionMessage, type TguPlatform, } from '../model/tguTypes' -import { toLocalIso } from '../utils/localTime' 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) на интервале для отрисовки на карте/таймлайне. - * @param noradId NORAD-идентификатор КА. - * @param fromMs Начало интервала, мс от эпохи. - * @param toMs Конец интервала, мс от эпохи. - * - * @returns Точки трассы полёта на интервале. + * Запросы планирования ТГУ идут напрямую в pcp-tgu-service через gateway (`/api/pcp-tgu` → + * :7011), как и остальные tgu-вызовы (см. tguPlanApi.ts). Решение/подтверждение закладки — + * на тест-контроллере ТГУ (`/test/tgu/*`, гейт `tgu.test-controller.enabled`, MVP-канал). */ -export async function fetchFlightLine( - noradId: string, - fromMs: number, - toMs: number, -): Promise { - const query = new URLSearchParams({ - time_start: toLocalIso(fromMs), - time_stop: toLocalIso(toMs), - }) - return fetchJson( - `/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`, - ) -} +const BASE = '/api/pcp-tgu' /** * Список платформ (КА) ТГУ. * @returns Платформы планирования ТГУ. */ export async function fetchPlatforms(): Promise { - return fetchJson('/api/tgu-planning/platforms') + return fetchJson(`${BASE}/api/platforms`) } /** @@ -64,8 +32,8 @@ export async function fetchPlatforms(): Promise { export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise { const query = new URLSearchParams({ historyDays: String(historyDays) }) const path = spacecraftId - ? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}` - : `/api/tgu-planning/plans?${query}` + ? `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}?${query}` + : `${BASE}/api/plans?${query}` return fetchJson(path) } @@ -89,7 +57,7 @@ export async function sendPlanDecision( } return fetchJson( - `/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`, + `${BASE}/test/tgu/plans/${encodeURIComponent(planId)}/decision?${query}`, { method: 'POST' }, true, ) @@ -101,7 +69,7 @@ export async function sendPlanDecision( * @param planId Идентификатор плана. */ export async function dismissPlan(planId: string): Promise { - await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, { + await fetchJson(`${BASE}/api/plans/${encodeURIComponent(planId)}/dismiss`, { method: 'POST', }) } @@ -111,7 +79,7 @@ export async function dismissPlan(planId: string): Promise { * @param planId Идентификатор плана. */ export async function restorePlan(planId: string): Promise { - await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, { + await fetchJson(`${BASE}/api/plans/${encodeURIComponent(planId)}/restore`, { method: 'POST', }) } @@ -122,7 +90,7 @@ export async function restorePlan(planId: string): Promise { * @param planId Идентификатор плана. */ export async function confirmPlanLayin(planId: string): Promise { - await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, { + await fetchJson(`${BASE}/test/tgu/plans/${encodeURIComponent(planId)}/confirm-layin`, { method: 'POST', }) } diff --git a/services/pcp-tgu-ui-service/src/features/constellation/realData.ts b/services/pcp-tgu-ui-service/src/features/constellation/realData.ts index 3558a0e..a6e2034 100644 --- a/services/pcp-tgu-ui-service/src/features/constellation/realData.ts +++ b/services/pcp-tgu-ui-service/src/features/constellation/realData.ts @@ -10,10 +10,10 @@ Возвращает { env, domain, sched } в том же виде, что buildEnv() прототипа, так что ConstellationDashboard рендерит реальные данные без изменений разметки. ============================================================ */ -import { fetchPlatforms, fetchFlightLine, fetchPlans, type FlightLinePoint } from "../../api/tguApi"; +import { fetchPlatforms, fetchPlans } from "../../api/tguApi"; import { fetchStations, type StationDto } from "../../api/stationsApi"; 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 type { TguPlatform, TguPlan } from "../../model/tguTypes"; import type { RequestMapItem } from "../../model/requestTypes"; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx index a4335e6..3b6dc34 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -1,10 +1,9 @@ import { useEffect, useMemo, useRef, useState, useCallback, type MutableRefObject } from "react"; import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchStations, type StationDto } from "../../api/stationsApi"; -import { fetchFlightLine, type FlightLinePoint } from "../../api/tguApi"; import type { RequestMapItem } from "../../model/requestTypes"; 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 { buildSurveyRoute, calculateDrops, diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts index ceab45e..4e6b852 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/editorApi.ts @@ -1,3 +1,5 @@ +import { apiFetch } from "../../api/apiFetch"; + export type PlanSurveyMode = { id: number; planId: number; @@ -30,9 +32,10 @@ export type PlanDropMode = { export type PlanMode = PlanSurveyMode | PlanDropMode; export async function fetchPlanModes(planId: string): Promise { - const response = await fetch(`/api/current-plans/missions/${encodeURIComponent(planId)}/modes`, { - headers: { Accept: "application/json" } - }); + const response = await apiFetch( + `/api/pcp-mission/api/missions/${encodeURIComponent(planId)}/modes`, + { headers: { Accept: "application/json" } } + ); if (!response.ok) { throw new Error(`Ошибка загрузки включений плана: ${response.status}`); } From 11380fced5f754440e0efaa7a7e35a6579aeae07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 15:46:57 +0300 Subject: [PATCH 04/11] =?UTF-8?q?fix(tgu-ui):=20=D0=B3=D0=B0=D1=81=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=C2=AB=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=C2=BB=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=B2=D1=88=D0=B5=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20(replace=E2=86=92422)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка предлагала replace-черновик для уже стартовавшего плана, хотя бэк (CreateDraftUseCase.createReplaceDraft) возвращает 422 «окно выдачи заморожено». Гард canCreateDraftFromPlan теперь требует startInFuture (зеркалит редактор); для стартовавших eligible-планов вместо кнопки — amber-подсказка про fork-forward. --- .../features/tgu-planning/TguPlanDetails.tsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx index 5de6831..66f7089 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanDetails.tsx @@ -82,14 +82,20 @@ export function TguPlanDetails({ const duration = formatDuration(plan.endMs - plan.startMs); // Окно решения закрылось, если с момента старта плана прошло больше grace-периода. const decisionWindowClosed = Date.now() > plan.startMs + DECISION_GRACE_MINUTES * 60_000; - // Закладку нельзя подтверждать, пока начало плана ещё в будущем: план закладывается на КА - // по факту наступления своего старта, а не заранее. - const layinStartInFuture = plan.startMs > Date.now(); + // Начало плана ещё в будущем. Нужно и для закладки (план закладывается по факту старта, + // не заранее), и для replace-черновика (после старта окно выдачи заморожено — бэк вернёт 422). + const startInFuture = plan.startMs > Date.now(); // «Создать черновик на основе плана» (replace, окно 1:1, все включения) доступно для - // персистентных правимых баз. DRAFT уже имеет свои действия; PLANNED — виртуальная проекция, - // не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован, только fork-forward). - const canCreateDraftFromPlan = + // персистентных правимых баз, у которых старт ещё не наступил. DRAFT уже имеет свои действия; + // PLANNED — виртуальная проекция, не база; LAID_IN — replace 1:1 запрещён (слот зафиксирован); + // стартовавший план — окно выдачи заморожено (CreateDraftUseCase.createReplaceDraft → 422), + // продолжение возможно только fork-forward (отдельный поток). Гард зеркалит редактор, чтобы + // не предлагать кнопку, которая заведомо упадёт «Unprocessable Content». + const draftableStatus = plan.status !== "DRAFT" && plan.status !== "PLANNED" && plan.status !== "LAID_IN"; + const canCreateDraftFromPlan = draftableStatus && startInFuture; + // Статус позволял бы replace-черновик, но план уже стартовал — объясняем, почему кнопки нет. + const draftBlockedByStart = draftableStatus && !startInFuture; const reject = () => { const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED"); @@ -229,15 +235,23 @@ export function TguPlanDetails({ )} + {draftBlockedByStart && ( +
+ Черновик-копию (replace, то же окно) можно создать только до старта плана — окно + выдачи уже заморожено. Продолжение цепочки на будущую ЗРВ (fork-forward) выполняется + отдельно. +
+ )} + {plan.status === "AWAITING_LAYIN" && onConfirmLayin && (
ТЕСТОВЫЙ ФУНКЦИОНАЛ
- {layinStartInFuture && ( + {startInFuture && (
Начало плана ещё в будущем — подтвердить закладку можно только после наступления его старта.
)} - {!layinStartInFuture && layinConflict && ( + {!startInFuture && layinConflict && (
По КА уже есть заложенный план со стартом в пределах ±10 минут — подтвердить нельзя.
From bdbc874f419f4254be320d433055abd0f0ba6f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 15:47:18 +0300 Subject: [PATCH 05/11] =?UTF-8?q?feat(tgu-ui):=20=D1=82=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BE=D1=80=D0=B8=D1=82=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B8=20=D0=B8=D0=BD=D1=81=D0=BF=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5=20?= =?UTF-8?q?=C2=AB=D0=9A=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BF=D0=BB=D0=B0=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Чекбокс «Тепловая карта» — фоновый слой-заливка ячеек сетки заданий по важности (cool→hot), источник GET /api/pcp-request/v1/cells/priority-map (cellsApi + drawHeatmap). Heatmap — полноценный слой MapPolygon (layer "heatmap", importance/cellNum), участвует в spatial-индексе и chooser, рисуется под контурами плана/заявок. Клик по объекту открывает правый инспектор ComplexInfoPanel (по аналогии с редактором): карточки ячейки приоритета, включения комплексного плана и заявки; при наложении — список объектов под точкой клика с раскрытием. При включённой тепловой карте ячейка всегда идёт первой в списке и раскрыта по умолчанию. Бэклог #44 отмечен сделанным. --- docs/бэклог-pcp-tgu-ops-ui.md | 1 + .../pcp-tgu-ui-service/src/api/cellsApi.ts | 85 ++++++ .../tgu-complex-plan/ComplexInfoPanel.tsx | 242 ++++++++++++++++++ .../tgu-complex-plan/TguComplexPlanTab.tsx | 161 +++++++++++- .../src/features/tgu-editor/TguEditorTab.tsx | 1 + .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 2 + .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 19 +- .../features/tgu-map-2d/canvas/drawHeatmap.ts | 86 +++++++ .../tgu-map-2d/model/mapLayerTypes.ts | 2 + .../tgu-map-2d/model/mapSelectionTypes.ts | 6 +- .../src/features/tgu-map-2d/model/mapTypes.ts | 8 +- .../tgu-map-2d/tgu2DMapInterval.test.ts | 1 + .../features/tgu-requests/TguRequestsTab.tsx | 2 +- .../src/styles/tgu-editor.css | 2 + .../src/styles/tgu-planning.css | 23 ++ 15 files changed, 623 insertions(+), 18 deletions(-) create mode 100644 services/pcp-tgu-ui-service/src/api/cellsApi.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index 9fcaf4f..f256e83 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -34,6 +34,7 @@ _Обновлено: 2026-06-10_ ### Комплексный план - [ ] **#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). diff --git a/services/pcp-tgu-ui-service/src/api/cellsApi.ts b/services/pcp-tgu-ui-service/src/api/cellsApi.ts new file mode 100644 index 0000000..e6818f7 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/cellsApi.ts @@ -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 { + 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 + 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 +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx new file mode 100644 index 0000000..ca36cb0 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/ComplexInfoPanel.tsx @@ -0,0 +1,242 @@ +import type { ReactNode } from "react"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import type { ComplexPlanMode } from "../../api/complexPlanApi"; +import type { PriorityCell } from "../../api/cellsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; +import { formatVolume } from "../../utils/dataVolume"; + +type ComplexInfoPanelProps = { + /** Объекты под точкой клика (1 — карточка сразу, ≥2 — список с раскрытием). */ + hits: Tgu2DMapSelection[]; + /** id раскрытого объекта в списке. */ + activeId?: string; + onToggle: (selection: Tgu2DMapSelection) => void; + onClose: () => void; + /** Режимы текущего прогона — для карточки включения комплексного плана. */ + modes: ComplexPlanMode[]; + /** Заявки активного интервала — для карточки заявки (если включён показ заявок). */ + requestItems: RequestMapItem[]; + /** Ячейки карты приоритетов — для карточки ячейки тепловой карты. */ + priorityCells: PriorityCell[]; +}; + +/** + * Правый инспектор вкладки «Комплексный план» (по аналогии с редактором): показывает данные + * объекта, выбранного на карте — включения комплексного плана, заявки или ячейки тепловой карты. + * При наложении объектов даёт список под точкой клика с раскрытием карточки нужного. + */ +export function ComplexInfoPanel({ + hits, + activeId, + onToggle, + onClose, + modes, + requestItems, + priorityCells, +}: ComplexInfoPanelProps) { + const renderCard = (selection: Tgu2DMapSelection): ReactNode => { + switch (selection.layer) { + case "complexPlan": + return ; + case "requests": + return ; + case "heatmap": + return ; + default: + return
{selection.name || selection.id}
; + } + }; + + if (hits.length <= 1) { + const selection = hits[0]; + return ( +
+ + {selection ? renderCard(selection) :
Объект не выбран.
} +
+ ); + } + + return ( +
+ +
Кликните объект в списке, чтобы раскрыть его данные.
+
+ {hits.map((hit, index) => { + const open = hit.id === activeId; + return ( +
+ + {open &&
{renderCard(hit)}
} +
+ ); + })} +
+
+ ); +} + +function ModeCard({ selection, modes }: { selection: Tgu2DMapSelection; modes: ComplexPlanMode[] }) { + const mode = selection.type === "polygon" ? modes.find((m) => m.id === selection.modeId) : undefined; + if (!mode) return
{selection.name || selection.id}
; + return ( +
+
{selection.name}
+
+
КА
+
{mode.satelliteId}
+
Тип
+
{modeTypeLabel(mode.type)}
+
Виток
+
{mode.revolution}
+
Начало
+
{formatIso(mode.startTime)}
+
Конец
+
{formatIso(mode.endTime)}
+
Длительность, с
+
{mode.duration?.toFixed(1) ?? "—"}
+
Крен, °
+
{mode.roll != null ? mode.roll.toFixed(1) : "—"}
+
Объём
+
{mode.volumeMb != null ? formatVolume(mode.volumeMb) : "—"}
+
№ ячейки
+
{mode.cellNum || "—"}
+
Источник
+
{mode.source || "—"}
+
+
+ ); +} + +function RequestCard({ selection, requestItems }: { selection: Tgu2DMapSelection; requestItems: RequestMapItem[] }) { + const item = + selection.type === "polygon" && selection.requestId + ? requestItems.find((r) => r.id === selection.requestId) + : undefined; + if (!item) return
{selection.name || selection.id}
; + return ( +
+
+
ID
+
{item.id}
+
Название
+
{item.name || "—"}
+
Статус
+
{item.status}
+
Тип съёмки
+
{surveyTypeLabel(item.surveyType)}
+
Начало
+
{formatIso(item.beginDateTime)}
+
Конец
+
{formatIso(item.endDateTime)}
+
Важность
+
{item.importance}
+
Покрытие
+
{item.coveragePercent != null ? `${item.coveragePercent.toFixed(1)} %` : "—"}
+
+
+ ); +} + +function CellCard({ selection, priorityCells }: { selection: Tgu2DMapSelection; priorityCells: PriorityCell[] }) { + const cell = + selection.type === "polygon" && selection.cellNum != null + ? priorityCells.find((c) => c.cellNum === selection.cellNum) + : undefined; + const importance = cell?.importance ?? (selection.type === "polygon" ? selection.importance : undefined); + return ( +
+
{selection.name}
+
+
№ ячейки
+
{cell?.cellNum ?? (selection.type === "polygon" ? selection.cellNum : undefined) ?? "—"}
+
Важность
+
{importance != null ? importance.toFixed(4) : "—"}
+ {cell && ( + <> +
Центр
+
{cell.latitude.toFixed(2)}°, {cell.longitude.toFixed(2)}°
+
Заявок
+
{cell.requests.length}
+ + )} +
+ {cell && cell.requests.length > 0 && ( +
+
Заявки в ячейке
+
+ {cell.requests.map((req) => ( + + ))} +
+
+ )} +
+ ); +} + +function ReqFragmentRow({ requestId, coveragePercent, importance }: { requestId: string; coveragePercent: number; importance: number }) { + return ( + <> +
{requestId.slice(0, 8)}…
+
покрытие {coveragePercent.toFixed(1)} % · важность {importance.toFixed(3)}
+ + ); +} + +function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { + return ( +
+

{title}

+ +
+ ); +} + +/** Заголовок/подпись типа объекта по слою выбора. */ +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 "Объект карты"; + } +} + +/** Класс цветовой метки в списке выбора (есть стили для requests/planWorks/swath/...). */ +function swatchKind(selection: Tgu2DMapSelection): string { + if (selection.type === "marker") return "station"; + if (selection.type === "line") return "drop"; + 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)); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx index 0183bba..157a4bb 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -9,6 +9,7 @@ import { type ComplexPlanRunSummary } from "../../api/complexPlanApi"; import { fetchRequestsForMap } from "../../api/requestApi"; +import { fetchPriorityCells, type PriorityCell } from "../../api/cellsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; 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 type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; 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 { ComplexInfoPanel } from "./ComplexInfoPanel"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatVolume } from "../../utils/dataVolume"; @@ -46,6 +49,18 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) const [requestsLoading, setRequestsLoading] = useState(false); const [requestsError, setRequestsError] = useState(); + // Тепловая карта приоритетов ячеек сетки заданий (фоновый слой, тумблер-чекбокс). + const [showHeatmap, setShowHeatmap] = useState(false); + const [priorityCells, setPriorityCells] = useState([]); + const [heatmapLoading, setHeatmapLoading] = useState(false); + const [heatmapError, setHeatmapError] = useState(); + const [heatmapLoaded, setHeatmapLoaded] = useState(false); + + // Инспектор объекта карты справа (по аналогии с редактором): выбранные под кликом объекты + // (1 — карточка сразу, ≥2 — список с раскрытием) и id раскрытого в списке. + const [mapHits, setMapHits] = useState([]); + const [activeHitId, setActiveHitId] = useState(); + // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). const satelliteIds = useMemo( () => @@ -89,9 +104,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) [showRequests, requestItems, range, platforms] ); + const heatmapPolygons = useMemo( + () => (showHeatmap ? buildHeatmapPolygons(priorityCells) : []), + [showHeatmap, priorityCells] + ); + const scene = useMemo( - () => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), - [modePolygons, requestPolygons] + () => ({ polygons: [...heatmapPolygons, ...modePolygons, ...requestPolygons], lines: [], markers: [] }), + [heatmapPolygons, modePolygons, requestPolygons] ); const layers = useMemo( @@ -100,11 +120,12 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) swath: false, planWorks: false, complexPlan: true, + heatmap: showHeatmap, stations: false, spacecraftMarkers: false, requests: showRequests }), - [showRequests] + [showHeatmap, showRequests] ); const loadRuns = useCallback(async (): Promise => { @@ -121,6 +142,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) const loadRunModes = useCallback(async (runId: number) => { setSelectedRunId(runId); + setMapHits([]); + setActiveHitId(undefined); setLoadingRun(true); setError(undefined); try { @@ -165,6 +188,57 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) }; }, [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 () => { if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return; setRunning(true); @@ -253,25 +327,56 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) > {showRequests ? "Скрыть заявки" : "Показать заявки"} +
{displayRun && (
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)} {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров {showRequests ? ` · заявок ${requestItems.length}` : ""} + {showHeatmap ? ` · ячеек ${heatmapPolygons.length}` : ""}
)}
-
- {overlayMessage &&
{overlayMessage}
} - undefined} - /> -
+
0 ? "minmax(0, 1fr) 22rem" : "minmax(0, 1fr) 0" }} + > +
+ {overlayMessage &&
{overlayMessage}
} + +
+ + {mapHits.length > 0 && ( + + )} +
{satelliteIds.length === 0 && ( @@ -286,6 +391,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) )} {requestsLoading &&
Загрузка заявок…
} {requestsError &&
{requestsError}
} + {heatmapLoading &&
Загрузка карты приоритетов…
} + {heatmapError &&
{heatmapError}
} + {showHeatmap && heatmapLoaded && !heatmapError && priorityCells.length === 0 && ( +
+ Карта приоритетов пуста — на ячейки сетки заданий не спроецировано ни одной заявки + (нужна пересборка сетки в сервисе заданий). +
+ )} {runsError &&
{runsError}
} {notice &&
{notice}
} {error &&
{error}
} @@ -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) → полигоны слоя комплексного плана. Сбросы без контура пропускаем. */ function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { const polygons: MapPolygon[] = []; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx index 3b6dc34..b54a82d 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -89,6 +89,7 @@ const EDITOR_MAP_LAYERS: Tgu2DMapLayersState = { swath: true, planWorks: true, complexPlan: false, + heatmap: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx index b02477a..345c1a5 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -314,6 +314,8 @@ function layerLabel(layer: Tgu2DMapLayerKey): string { return "Работы плана"; case "complexPlan": return "Комплексный план"; + case "heatmap": + return "Тепловая карта"; case "stations": return "Станции"; case "spacecraftMarkers": diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index ac200bb..67f3b11 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { drawBaseMap } from "./canvas/drawBaseMap"; import { drawComplexPlan } from "./canvas/drawComplexPlan"; +import { drawHeatmap } from "./canvas/drawHeatmap"; import { drawPlanWorks } from "./canvas/drawPlanWorks"; import { drawRequests } from "./canvas/drawRequests"; import { drawStations } from "./canvas/drawStations"; @@ -193,6 +194,9 @@ export function Tgu2DMapView({ drawTiles(context, view, projection, size, handleTileLoad); drawBaseMap(context, projection); + if (layers.heatmap) { + drawHeatmap(context, visiblePolygons.filter((polygon) => polygon.layer === "heatmap"), projection, size); + } if (layers.requests) { drawRequests(context, visiblePolygons.filter((polygon) => polygon.layer === "requests"), projection, size); } @@ -319,7 +323,14 @@ export function Tgu2DMapView({ if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { - if (polygon.layer !== "requests" && polygon.layer !== "planWorks") continue; + if ( + polygon.layer !== "requests" && + polygon.layer !== "planWorks" && + polygon.layer !== "complexPlan" && + polygon.layer !== "heatmap" + ) { + continue; + } hits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); } @@ -603,6 +614,8 @@ function layerTitle(layer: MapPolygon["layer"]): string { return "Полоса обзора"; case "complexPlan": return "Комплексный план"; + case "heatmap": + return "Ячейка приоритета"; case "tracks": return "Трасса КА"; case "requests": @@ -708,7 +721,9 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { requestId: polygon.requestId, surveyType: polygon.surveyType, status: polygon.status, - modeId: polygon.modeId + modeId: polygon.modeId, + importance: polygon.importance, + cellNum: polygon.cellNum }; } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts new file mode 100644 index 0000000..3caaf4a --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/canvas/drawHeatmap.ts @@ -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), + ]; +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts index 6dff8ab..18e7f69 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapLayerTypes.ts @@ -3,6 +3,7 @@ export type Tgu2DMapLayerKey = | "swath" | "planWorks" | "complexPlan" + | "heatmap" | "stations" | "spacecraftMarkers" | "requests"; @@ -38,6 +39,7 @@ export const DEFAULT_TGU_2D_MAP_LAYERS: Tgu2DMapLayersState = { swath: false, planWorks: true, complexPlan: false, + heatmap: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts index e2af403..d774a65 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -5,7 +5,7 @@ export type Tgu2DMapSelection = type: "polygon"; id: string; name: string; - layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap"; planId?: string; spacecraftId?: string; requestId?: string; @@ -13,6 +13,10 @@ export type Tgu2DMapSelection = status?: TguPlanStatus; /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ modeId?: number; + /** Важность ячейки тепловой карты (layer "heatmap"). */ + importance?: number; + /** Номер ячейки сетки заданий (layer "heatmap"). */ + cellNum?: number; } | { type: "line"; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts index 227bf17..6c139d8 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts @@ -24,8 +24,8 @@ export type MapViewState = { export type MapPolygon = { id: string; name: string; - kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan"; - layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan"; + kind: "planWork" | "swath" | "trackZone" | "request" | "complexPlan" | "heatmap"; + layer: "planWorks" | "swath" | "tracks" | "requests" | "complexPlan" | "heatmap"; points: GeoPoint[]; status?: TguPlanStatus; planId?: string; @@ -34,6 +34,10 @@ export type MapPolygon = { surveyType?: string; /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ modeId?: number; + /** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */ + importance?: number; + /** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */ + cellNum?: number; zIndex: number; }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts index 1d5f154..42129d1 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/tgu2DMapInterval.test.ts @@ -52,6 +52,7 @@ describe("tgu2DMapInterval", () => { swath: false, planWorks: true, complexPlan: false, + heatmap: false, stations: true, spacecraftMarkers: false, requests: true diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx index 4e7b3ab..0dc7dbe 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx @@ -123,7 +123,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa ); 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] ); diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css index 7f86e9f..aea13cc 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css @@ -1159,6 +1159,8 @@ .tgu-editor-pick__swatch--drop { background: #ffcf3f; } .tgu-editor-pick__swatch--station { background: #d5e2e2; } .tgu-editor-pick__swatch--swath { background: #6fb7ff; } +.tgu-editor-pick__swatch--complexPlan { background: #c080ff; } +.tgu-editor-pick__swatch--heatmap { background: #f5803e; } .tgu-editor-pick__title { font-size: 0.74rem; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index cd56a02..0d9a2b0 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -733,6 +733,22 @@ 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 { display: grid; grid-template-columns: 19rem minmax(0, 1fr); @@ -883,6 +899,13 @@ border-radius: 999px; } +/* Тело вкладки «Комплексный план»: карта + правый инспектор объекта (как в редакторе). */ +.tgu-complex-body { + display: grid; + min-width: 0; + min-height: 0; +} + .tgu-map-stage { position: relative; min-width: 0; From 7202f7cb028398e6dccd62bfec770300235d63ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 15:58:55 +0300 Subject: [PATCH 06/11] =?UTF-8?q?refactor(tgu-ui):=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B8=D0=BD=D1=81=D0=BF=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BA=D0=B0=D1=80=D1=82=D1=8B=20(MapObjectInspector)=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=D1=85?= =?UTF-8?q?=20=C2=AB=D0=9A=D0=B0=D1=80=D1=82=D0=B0=C2=BB=20=D0=B8=20=C2=AB?= =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BB=D0=B0=D0=BD=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обобщил ComplexInfoPanel в общий MapObjectInspector (tgu-map-2d): карточки для всех типов объектов — заявка, включение комплексного плана, ячейка тепловой карты, маршрут planWorks, станция, маркер КА, линия; список с раскрытием при наложении. Вкладка «Карта» переведена на enableObjectChooser + onObjectsAtPoint + правый инспектор — заменила разрозненные Tgu2DMapRequestsPanel/Tgu2DMapStationPanel (удалены) и объектную часть нижней плашки (Tgu2DMapDetails ужат до контекста плана). Вкладка «Комплексный план» использует тот же инспектор. RequestAtPoint переехал в tgu-requests/RequestDetailsPanel (нужен вкладке «Заявки»). --- .../tgu-complex-plan/TguComplexPlanTab.tsx | 4 +- .../MapObjectInspector.tsx} | 152 ++++++++++++++---- .../features/tgu-map-2d/Tgu2DMapDetails.tsx | 17 +- .../tgu-map-2d/Tgu2DMapRequestsPanel.tsx | 116 ------------- .../tgu-map-2d/Tgu2DMapStationPanel.tsx | 43 ----- .../src/features/tgu-map-2d/Tgu2DMapTab.tsx | 92 ++++------- .../tgu-requests/RequestDetailsPanel.tsx | 6 +- .../features/tgu-requests/TguRequestsTab.tsx | 2 +- .../src/styles/tgu-editor.css | 2 + 9 files changed, 164 insertions(+), 270 deletions(-) rename services/pcp-tgu-ui-service/src/features/{tgu-complex-plan/ComplexInfoPanel.tsx => tgu-map-2d/MapObjectInspector.tsx} (57%) delete mode 100644 services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx delete mode 100644 services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx diff --git a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx index 157a4bb..d56145f 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -19,7 +19,7 @@ import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; 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 { ComplexInfoPanel } from "./ComplexInfoPanel"; +import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatVolume } from "../../utils/dataVolume"; @@ -365,7 +365,7 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) {mapHits.length > 0 && (
-
-
Объект карты
-
{selectedObject?.name || selectedObject?.id || "-"}
-
-
-
Работы
-
Работы плана
-
); } - diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx deleted file mode 100644 index 29cac64..0000000 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapRequestsPanel.tsx +++ /dev/null @@ -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 ( - - ); -} - -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}`; -} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx deleted file mode 100644 index fab4765..0000000 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapStationPanel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; - -type StationSelection = Extract & { layer: "stations" }; - -type Props = { - selection: StationSelection; - onClose: () => void; -}; - -export function Tgu2DMapStationPanel({ selection, onClose }: Props) { - return ( - - ); -} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx index 345c1a5..7d8ec08 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapTab.tsx @@ -6,12 +6,10 @@ import type { TguPlanUi, TguPlatformUi, TimelineRange } from "../../model/timeli import { platformLabel } from "../tgu-planning/tguTimelineMapper"; import { DEFAULT_TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } from "./model/mapLayerTypes"; import type { Tgu2DMapSelection } from "./model/mapSelectionTypes"; -import type { MapPolygon } from "./model/mapTypes"; import { buildTgu2DMapScene, getLayerAvailability } from "./Tgu2DMapLayers"; import { Tgu2DMapDetails } from "./Tgu2DMapDetails"; -import { Tgu2DMapRequestsPanel, type RequestAtPoint } from "./Tgu2DMapRequestsPanel"; +import { MapObjectInspector } from "./MapObjectInspector"; import { Tgu2DMapSidebar } from "./Tgu2DMapSidebar"; -import { Tgu2DMapStationPanel } from "./Tgu2DMapStationPanel"; import { Tgu2DMapToolbar } from "./Tgu2DMapToolbar"; import { Tgu2DMapView } from "./Tgu2DMapView"; import { getTgu2DMapIntervalState, TGU_2D_MAP_INTERVAL_WARNING } from "./tgu2DMapInterval"; @@ -58,15 +56,13 @@ export function Tgu2DMapTab({ const [layers, setLayers] = useState(DEFAULT_TGU_2D_MAP_LAYERS); const [search, setSearch] = useState(""); const [redrawToken, setRedrawToken] = useState(0); - const [selectedObject, setSelectedObject] = useState(); const [hiddenIds, setHiddenIds] = useState>(new Set()); const [requestsState, setRequestsState] = useState(() => buildInitialRequestsState(range)); const [stations, setStations] = useState([]); const [stationsFetchFailed, setStationsFetchFailed] = useState(false); - const [requestsAtPoint, setRequestsAtPoint] = useState([]); - const [selectedRequestPolygonId, setSelectedRequestPolygonId] = useState(); - const [hoveredRequestPolygonId, setHoveredRequestPolygonId] = useState(); - const [stationPanel, setStationPanel] = useState<(Tgu2DMapSelection & { type: "marker"; layer: "stations" }) | undefined>(); + // Инспектор объекта карты справа (единый для всех вкладок): объекты под кликом и раскрытый. + const [mapHits, setMapHits] = useState([]); + const [activeHitId, setActiveHitId] = useState(); useEffect(() => { fetchStations() @@ -183,47 +179,24 @@ export function Tgu2DMapTab({ setLayers((current) => ({ ...current, [key]: !current[key] })); }; + // Клик по карте: 0/1 объект — карточка сразу; ≥2 (наложение) — список под точкой клика. const handleObjectSelect = useCallback((selection?: Tgu2DMapSelection) => { - if (selection?.type === "marker" && selection.layer === "stations") { - setStationPanel(selection as Tgu2DMapSelection & { type: "marker"; layer: "stations" }); - setRequestsAtPoint([]); - setSelectedRequestPolygonId(undefined); - setHoveredRequestPolygonId(undefined); - setSelectedObject(undefined); - } else { - setStationPanel(undefined); - setSelectedObject(selection); - } + setMapHits(selection ? [selection] : []); + setActiveHitId(selection?.id); }, []); - const handleRequestsAtPoint = useCallback( - (polygons: MapPolygon[]) => { - if (polygons.length === 0) { - 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 handleObjectsAtPoint = useCallback((selections: Tgu2DMapSelection[]) => { + setMapHits(selections); + setActiveHitId(selections[0]?.id); }, []); - const closeStationPanel = useCallback(() => { - setStationPanel(undefined); + const toggleHit = useCallback((selection: Tgu2DMapSelection) => { + setActiveHitId((current) => (current === selection.id ? undefined : selection.id)); + }, []); + + const closeInfoPanel = useCallback(() => { + setMapHits([]); + setActiveHitId(undefined); }, []); return ( @@ -236,7 +209,7 @@ export function Tgu2DMapTab({ onToggleLayer={toggleLayer} /> -
0 || stationPanel != null) ? " has-requests-panel" : ""}`}> +
0 ? " has-side-panel" : ""}`}> - {requestsAtPoint.length > 0 && ( - - )} - {stationPanel != null && ( - + {mapHits.length > 0 && ( + )}
@@ -290,7 +262,7 @@ export function Tgu2DMapTab({ )}
- + ); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx index a19d202..4978357 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/RequestDetailsPanel.tsx @@ -1,7 +1,11 @@ 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"; +/** Заявка под точкой клика на карте: полигон-проекция заявки и сама заявка. */ +export type RequestAtPoint = { polygon: MapPolygon; item: RequestMapItem }; + type Props = { requests: RequestAtPoint[]; selectedPolygonId?: string; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx index 0dc7dbe..8c96420 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-requests/TguRequestsTab.tsx @@ -5,7 +5,7 @@ import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; 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 { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes"; import { generateUuid } from "../../utils/uuid"; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css index aea13cc..d6fad52 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-editor.css @@ -1161,6 +1161,8 @@ .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 { font-size: 0.74rem; From 802c966347d8260337d937d9bbef974f259b43be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 10 Jun 2026 16:13:59 +0300 Subject: [PATCH 07/11] =?UTF-8?q?fix(tgu-ui):=20=D0=BF=D0=BE=D0=B4=D1=85?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= =?UTF-8?q?=20=C2=ABConflict=C2=BB=20=D0=BF=D1=80=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=81=D1=8A?= =?UTF-8?q?=D1=91=D0=BC=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При первой правке плана ensureDraft→createDraft(replace) падал 409, если по КА уже есть открытый черновик на это окно (осиротевший с прошлой сессии — авто-отмена best-effort). В UI это показывалось голым «Conflict» (на стенде не подхвачен include-message у tgu). Теперь на 409 от createDraft редактор подхватывает существующий черновик того же КА с тем же окном (adoptExistingDraft через fetchDraftPlans, сверка окна ±2 c) и продолжает в нём; если подходящего нет — читаемое сообщение со ссылкой на вкладку «Планы» вместо «Conflict». --- .../src/features/tgu-editor/TguEditorTab.tsx | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx index b54a82d..59533fa 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -15,7 +15,9 @@ import { validateMission, type SurveyRoute } 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 { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -627,6 +629,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же // слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание // плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы». + // Ищет уже открытый DRAFT этого КА с тем же окном, что у базового плана (replace 1:1 совпадает + // по окну). Используется при 409 от createDraft, чтобы подхватить осиротевший черновик. + const adoptExistingDraft = useCallback(async (basePlan: TguPlanUi): Promise => { + if (!basePlan.spacecraftId) return undefined; + const drafts = await fetchDraftPlans(basePlan.spacecraftId).catch(() => [] as Awaited>); + 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 => { if (draftPlanId) return draftPlanId; if (!selectedPlan) throw new Error("План не выбран — нечего редактировать."); @@ -635,11 +650,32 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setDraftPlanId(selectedPlan.planId); return selectedPlan.planId; } - const created = await createDraft(selectedPlan.planId, { mode: "replace" }); - createdImplicitlyRef.current = true; - setDraftPlanId(created.draftPlanId); - return created.draftPlanId; - }, [draftPlanId, selectedPlan]); + try { + const created = await createDraft(selectedPlan.planId, { mode: "replace" }); + createdImplicitlyRef.current = true; + setDraftPlanId(created.draftPlanId); + 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. // MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок. From 6689351ef0aad68ad46c8f74f5fa2b1a8ef8fbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 11 Jun 2026 12:20:28 +0300 Subject: [PATCH 08/11] =?UTF-8?q?feat(tgu-ui):=20=D0=B2=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B0=20=C2=AB=D0=A1=D0=BB=D0=BE=D1=82=D1=8B=C2=BB?= =?UTF-8?q?=20=E2=80=94=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором, расчёт слотов покрытия, бронирование и снятие брони - выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре, кнопка «Сброс», фильтр заявок по интервалу - бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся) - подсветка и автоскролл активной строки по клику на слот (ручной scrollBy) - меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе - фильтр отображения по суткам (SlotDaySelector) - gateway: маршрут slots (/api/pcp-slots/**) - карточка полосы обзора (swath): КА и виток в инспекторе --- config-repo/pcp-gateway-service.yaml | 6 + docs/бэклог-pcp-tgu-ops-ui.md | 23 +- .../pcp-tgu-ui-service/src/api/slotsApi.ts | 115 +++ .../src/features/tgu-editor/EditorPanels.tsx | 11 + .../src/features/tgu-editor/TguEditorTab.tsx | 15 +- .../tgu-map-2d/MapObjectInspector.tsx | 26 +- .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 195 ++++- .../tgu-map-2d/geometry/spatialIndex.ts | 2 + .../tgu-map-2d/model/mapSelectionTypes.ts | 2 + .../src/features/tgu-map-2d/model/mapTypes.ts | 4 + .../features/tgu-planning/TguPlanningPage.tsx | 7 + .../src/features/tgu-planning/TguToolbar.tsx | 62 +- .../features/tgu-slots/SlotDaySelector.tsx | 115 +++ .../src/features/tgu-slots/SlotPickMenu.tsx | 48 ++ .../src/features/tgu-slots/TguSlotsTab.tsx | 801 ++++++++++++++++++ .../pcp-tgu-ui-service/src/model/slotTypes.ts | 55 ++ .../src/model/timelineTypes.ts | 9 +- .../src/styles/tgu-planning.css | 286 +++++++ 18 files changed, 1735 insertions(+), 47 deletions(-) create mode 100644 services/pcp-tgu-ui-service/src/api/slotsApi.ts create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx create mode 100644 services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx create mode 100644 services/pcp-tgu-ui-service/src/model/slotTypes.ts diff --git a/config-repo/pcp-gateway-service.yaml b/config-repo/pcp-gateway-service.yaml index 33c0611..1637751 100644 --- a/config-repo/pcp-gateway-service.yaml +++ b/config-repo/pcp-gateway-service.yaml @@ -57,6 +57,12 @@ spring: - 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} diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index f256e83..acb036f 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -13,7 +13,7 @@ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). -_Обновлено: 2026-06-10_ +_Обновлено: 2026-06-11_ --- @@ -21,7 +21,7 @@ _Обновлено: 2026-06-10_ ### Карта и отображение - [ ] **#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** — Показ забронированных слотов на вкладках «Карта» и «Редактор». Включает **подгрузку** данных по забронированным слотам с бэкенда (фетч), а не только отрисовку уже имеющихся. На вкладке «Редактор» они должны быть другого цвета и выделяться. ### Группировка @@ -38,6 +38,7 @@ _Обновлено: 2026-06-10_ ### Бэкенд / инфраструктура - [ ] **#7** — Перевести сервис станций на ordinis (по примеру получения аппаратов в сервисе tgu). +- [ ] **#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» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. ### Проверка сервисов @@ -49,10 +50,17 @@ _Обновлено: 2026-06-10_ - [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`) и удаление кода — **отложено** до проверки репойнта на живом стенде. Пока сервис задеплоен, но без маршрутов. -### Слоты (вкладка «Заявка» и «Редактор») -- [ ] **#41** — Запрос слотового покрытия по заявке. На вкладке «Заявка» (и в «Редакторе») запрашивать у slots-service слотовое покрытие заявки — какие слоты КА покрывают AOI заявки на её интервале. Источник — slots-service (минуя умирающий `pcp-ui-service`, ср. #40). Уточнить контракт ответа (слоты + связь с ячейками/покрытием заявки) и где он отрисовывается. -- [ ] **#42** — Бронирование слотов по заявке. С вкладки «Заявка» (и/или «Редактор») бронировать выбранные слоты под заявку через slots-service. Связано с показом забронированных слотов (#3) и пересчётом комплексного плана при бронировании (#6). Уточнить: единичное/пакетное бронирование, идемпотентность, что возвращается после брони. -- [ ] **#43** — Уточнение статуса слота. На вкладке «Заявка» (и «Редактор») показывать/обновлять актуальный статус слота (свободен / забронирован / занят по заложенному плану). Учитывать, что слоты занимаются по `LAID_IN`, а не по `ACCEPT` (ср. #10). Источник статусов — slots-service; согласовать с #41/#42. +### Слоты (вкладка «Слоты») +- [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** — Ошибка при попытке принять план на вкладке «Планы». @@ -105,4 +113,5 @@ _Источник и рекомендации: [аудит-сервис-зада Добавил вручную: -- убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают \ No newline at end of file +- ~~убрать кнопки + - квадрат с вкладки редактор и карта с карты ибо ничего не делают~~ _Решено иначе (2026-06-11): кнопки не убрали, а починили — они не срабатывали из-за `setPointerCapture` на контейнере карты в `onPointerDown` (перехватывал жест, и клик по кнопке внутри карты не доходил). Добавлен гард: нажатие в области `.tgu-2d-map__zoom` не запускает панорамирование/захват, плюс `stopPropagation` на кнопках. Теперь «+/−» (зум) и «□» (вернуть обзор) работают на всех вкладках с картой._ +- [x] **Линейка на карте** _(2026-06-11): инструмент измерения расстояния на всех вкладках с картой (Карта, Заявки, Комплексный план, Редактор, Слоты — общий `Tgu2DMapView`). Иконка-линейка в правом нижнем углу рядом с «+/−/□», включается/выключается кликом (тумблер). В активном режиме клик по карте ставит вершины замера, рисуется пунктирная ломаная с подписями накопленного расстояния по дуге большого круга (haversine), в бейдже — итоговая длина; «Сброс» очищает замер, повторный клик по иконке выключает. Реализовано целиком в `Tgu2DMapView` (состояние `rulerActive`/`rulerPoints`), стили `.tgu-2d-map__ruler*`._ \ No newline at end of file diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts new file mode 100644 index 0000000..622b23b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -0,0 +1,115 @@ +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 { + 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 Заявка, интервал и фильтры (ветвь, признак покрытия, КА, угол Солнца). + * + * @returns Список слотов-кандидатов с контурами для карты. + */ +export async function fetchRequestSlots(params: RequestSlotsParams): Promise { + 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)) + params.satellites?.forEach((id) => query.append('satellites', String(id))) + + const response = await apiFetch(`${BASE}/request-cover?${query}`, { + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось рассчитать слоты')) + } + + return response.json() as Promise +} + +/** + * Бронирует выбранные слоты под заявку (slots-service: POST /api/slots/booking/request). + * @param requestId Идентификатор заявки. + * @param slots Список слотов (КА, номер слота, цикл). + * + * @returns Забронированные слоты. + */ +export async function bookRequestSlots( + requestId: string, + slots: SlotBookingRequest[], +): Promise { + 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 +} + +/** + * Снимает все брони заявки (slots-service: DELETE /api/slots/booking/request). + * @param requestId Идентификатор заявки. + * + * @returns Сколько броней снято. + */ +export async function cancelRequestBooking(requestId: string): Promise { + 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 { + 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 +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx index e0794dd..aba0256 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/EditorPanels.tsx @@ -672,6 +672,17 @@ export function MapInfoPanel({ selection, requestItems, onClose }: MapInfoPanelP )} )} + + {selection.type === "polygon" && selection.layer === "swath" && ( +
+
{selection.name}
+
+ {selection.spacecraftId && (<>
КА
{selection.spacecraftId}
)} +
Виток
+
{selection.revolution ?? "—"}
+
+
+ )} ); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx index 59533fa..30081f5 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-editor/TguEditorTab.tsx @@ -755,7 +755,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planEnd: toLocalIso(selectedPlan.endMs) }); setSurveyRoute(savedRoute); - void refreshDraftModes(draftId); + // Ждём перечитывания planModes ДО снятия editingWorkId ниже: иначе на миг editingModeId + // перестаёт исключать старый режим из сцены, а свежие planModes ещё не пришли — и старый + // контур мелькает на карте, пока не сделаешь что-нибудь ещё. + await refreshDraftModes(draftId); } catch (error) { setRouteError(error instanceof Error ? error.message : "Не удалось сохранить маршрут съёмки."); return; @@ -794,6 +797,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setTarget(undefined); setTargetPickActive(false); setSelectedShootPass(undefined); + // Гасим превью окна построения сразу: сохранённый маршрут уже виден из planModes/черновика. + setSurveyRoute(undefined); + setRouteWindow(undefined); }; const stationNameById = useCallback( @@ -852,8 +858,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }); savedDropId = savedDrop.id; savedDropVolumeMb = savedDrop.volumeMb; - // Перечитываем включения DRAFT (источник истины) и ревалидируем. - void refreshDraftModes(draftId); + // Перечитываем включения DRAFT (источник истины) и ревалидируем. Ждём ДО снятия + // editingWorkId ниже — иначе старая трасса сброса мелькает, пока planModes не обновились. + await refreshDraftModes(draftId); } catch (error) { setDropError(error instanceof Error ? error.message : "Не удалось сохранить сброс."); return; @@ -1630,6 +1637,8 @@ function buildEditorMapScene( kind: "swath", layer: "swath", points: [...leftEdge, ...rightEdge], + spacecraftId, + revolution, zIndex: zIndex++ }); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx index 0861b75..5205a93 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/MapObjectInspector.tsx @@ -27,6 +27,8 @@ type MapObjectInspectorProps = { requestItems?: RequestMapItem[]; /** Ячейки карты приоритетов — для карточки ячейки тепловой карты (layer "heatmap"). */ priorityCells?: PriorityCell[]; + /** Если задан — в карточке заявки появляется кнопка «Выбрать» (вкладка «Слоты»). */ + onSelectRequest?: (requestId: string) => void; }; export function MapObjectInspector({ @@ -37,6 +39,7 @@ export function MapObjectInspector({ modes = [], requestItems = [], priorityCells = [], + onSelectRequest, }: MapObjectInspectorProps) { const renderCard = (selection: Tgu2DMapSelection): ReactNode => { if (selection.type === "marker") { @@ -49,7 +52,7 @@ export function MapObjectInspector({ case "complexPlan": return ; case "requests": - return ; + return ; case "heatmap": return ; case "planWorks": @@ -129,7 +132,15 @@ function ModeCard({ selection, modes }: { selection: PolygonSelection; modes: Co ); } -function RequestCard({ selection, requestItems }: { selection: PolygonSelection; requestItems: RequestMapItem[] }) { +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
{selection.name || selection.id}
; return ( @@ -156,6 +167,17 @@ function RequestCard({ selection, requestItems }: { selection: PolygonSelection;
Приоритет передачи
{item.highPriorityTransmit ? "Да" : "Нет"}
+ {onSelectRequest && ( +
+ +
+ )} ); } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index 67f3b11..b0c30e5 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -112,6 +112,10 @@ export function Tgu2DMapView({ const [tooltip, setTooltip] = useState(); // Гео-координаты под курсором — показываем по центру внизу карты. const [cursorGeo, setCursorGeo] = useState(); + // Линейка: включается кнопкой в правом нижнем углу; в активном режиме клик по карте + // добавляет вершину замера, а наложение рисует ломаную с накопленным расстоянием. + const [rulerActive, setRulerActive] = useState(false); + const [rulerPoints, setRulerPoints] = useState([]); const selectedPolygonRef = useRef(undefined); const selectedLineRef = useRef(undefined); const activePolygons = useMemo( @@ -256,6 +260,10 @@ export function Tgu2DMapView({ ); const onPointerDown = (event: React.PointerEvent) => { + // Нажатие на кнопки управления картой (зум, линейка, «Сброс») не должно запускать перетаскивание + // и захват указателя: setPointerCapture на контейнере иначе перехватывает жест, и кнопки внутри + // карты перестают «кликаться» (родная причина того, что +/−/□ и «Сброс» не срабатывали). + if ((event.target as HTMLElement).closest("button")) return; const screenPoint = screenPointFromEvent(event); hasDraggedRef.current = false; dragRef.current = { @@ -303,6 +311,14 @@ export function Tgu2DMapView({ wrapperRef.current?.releasePointerCapture(event.pointerId); }; + // Линейка: переключаем режим; при выключении замер очищаем. + const toggleRuler = () => { + setRulerActive((active) => { + if (active) setRulerPoints([]); + return !active; + }); + }; + // Применить выбор конкретного объекта (из меню выбора либо при единственном попадании): // выставляем внутреннюю подсветку и сообщаем наверх через onObjectSelect. const applyEditorHit = useCallback((hit: EditorHit) => { @@ -313,16 +329,24 @@ export function Tgu2DMapView({ }, [onObjectSelect]); // Клик в редакторе: собираем ВСЕ интерактивные объекты под точкой (заявки, маршруты съёмки, - // сбросы, станции). 0 → возможно полоса обзора под низом; 1 → выбираем сразу; + // сбросы, станции). 0 → смотрим на полосы обзора; 1 → выбираем сразу; // ≥2 → отдаём список наверх (оператор выбирает нужный в правой панели). const handleEditorClick = useCallback((screenPoint: ScreenPoint) => { const geoPoint = projection.unproject(screenPoint); const hits: EditorHit[] = []; + // Полосы обзора собираем отдельно: они почти всегда лежат под кликом и не должны + // превращать выбор заявки/маршрута в список. Их список показываем, только когда под точкой + // нет другого интерактивного объекта, а самих полос несколько (наложение соседних витков). + const swathHits: EditorHit[] = []; const marker = hitTestMarkers(activeMarkers, screenPoint, projection); if (marker) hits.push({ kind: "marker", marker, selection: markerToSelection(marker) }); for (const polygon of hitTestAllPolygons(polygonIndex, geoPoint)) { + if (polygon.layer === "swath") { + swathHits.push({ kind: "polygon", polygon, selection: polygonToSelection(polygon) }); + continue; + } if ( polygon.layer !== "requests" && polygon.layer !== "planWorks" && @@ -340,19 +364,38 @@ export function Tgu2DMapView({ } if (hits.length >= 2 && onObjectsAtPoint) { - // Несколько объектов — внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). + // Несколько интерактивных объектов — показываем список. В него добавляем и полосы обзора, + // лежащие под точкой, чтобы их данные тоже были видны. Порядок: маршрут → заявка → полоса. + const combined = [...hits, ...swathHits].sort((a, b) => editorHitRank(a) - editorHitRank(b)); + // Внутреннюю подсветку снимаем (её ведёт правая панель через selectedObjectId). selectedPolygonRef.current = undefined; selectedMarkerRef.current = undefined; selectedLineRef.current = undefined; - onObjectsAtPoint(hits.map((hit) => hit.selection)); + onObjectsAtPoint(combined.map((hit) => hit.selection)); return; } - if (hits.length >= 1) { + if (hits.length === 1) { + // Единственный интерактивный объект — выбираем сразу (полосы обзора лежат под кликом почти + // всегда, поэтому ради одного объекта список не показываем — иначе любой клик стал бы списком). applyEditorHit(hits[0]); 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); selectedPolygonRef.current = fallback; selectedMarkerRef.current = undefined; @@ -363,6 +406,11 @@ export function Tgu2DMapView({ const onClick = (event: React.MouseEvent) => { if (hasDraggedRef.current) return; const screenPoint = screenPointFromEvent(event); + if (rulerActive) { + // Режим линейки: клик добавляет вершину замера, выбор объектов не трогаем. + setRulerPoints((points) => [...points, projection.unproject(screenPoint)]); + return; + } if (drawMode) { onDrawAddPoint?.(projection.unproject(screenPoint)); return; @@ -449,8 +497,22 @@ export function Tgu2DMapView({ })); }; - const selectedPolygon = selectedPolygonRef.current; - const selectedMarker = selectedMarkerRef.current; + // Внутренние ref-выделения (клик по карте) могут указывать на объект, которого уже нет в сцене — + // например, контур съёмки, скрытый при входе в режим правки (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 id = selectedRequestPolygonId ?? selectedObjectId; return id ? activePolygons.find((p) => p.id === id) : undefined; @@ -461,7 +523,7 @@ export function Tgu2DMapView({ [selectedObjectId, scene.lines] ); // Линии под подсветкой: выбранная кликом на карте (внутренняя) и заданная извне (по selectedObjectId). - const overlayLines = uniqueLines([selectedLineRef.current, externalSelectedLine]); + const overlayLines = uniqueLines([selectedLine, externalSelectedLine]); const externalHoveredPolygon = useMemo( () => hoveredRequestPolygonId ? activePolygons.find((p) => p.id === hoveredRequestPolygonId) : undefined, [hoveredRequestPolygonId, activePolygons] @@ -469,9 +531,21 @@ export function Tgu2DMapView({ const overlayPolygons = uniquePolygons([externalSelectedPolygon, selectedPolygon, externalHoveredPolygon, hoverRef.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 (
); })()} + {rulerPoints.length > 0 && (() => { + const pts = unwrapX(rulerPoints.map((point) => projection.project(point)), projection.worldSize); + return ( + + {pts.length >= 2 && ( + `${p.x},${p.y}`).join(" ")} + fill="none" + /> + )} + {pts.map((p, i) => ( + + + + {i === 0 ? "0" : formatDistanceKm(rulerCumKm[i])} + + + ))} + + ); + })()} {tooltip && (
@@ -588,15 +684,69 @@ export function Tgu2DMapView({ )} {cursorGeo &&
{formatLatLon(cursorGeo)}
}
© OpenStreetMap contributors · © CARTO
+ {rulerActive && ( +
+ Линейка + + {rulerPoints.length < 2 ? "кликайте по карте" : formatDistanceKm(rulerTotalKm)} + + {rulerPoints.length > 0 && ( + + )} +
+ )}
- - - + + + +
); } +/** Расстояние между двумя гео-точками по дуге большого круга (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 { const lat = point.lat; @@ -695,6 +845,26 @@ type EditorHit = | { kind: "polygon"; polygon: MapPolygon; 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 { return { type: "marker", @@ -722,6 +892,7 @@ function polygonToSelection(polygon: MapPolygon): Tgu2DMapSelection { surveyType: polygon.surveyType, status: polygon.status, modeId: polygon.modeId, + revolution: polygon.revolution, importance: polygon.importance, cellNum: polygon.cellNum }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts index 31463fb..e64499f 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/geometry/spatialIndex.ts @@ -24,6 +24,7 @@ export function hitTestAllPolygons(index: PolygonSpatialIndex, point: GeoPoint): const hits: MapPolygon[] = []; for (const entry of index.entries) { + if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён const inBbox = bboxContainsPoint(entry.bbox, point) || 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; for (const entry of index.entries) { + if (entry.polygon.pickable === false) continue; // контур отрисовывается, но из выбора исключён // Check bbox allowing ±360° shifts so antimeridian-crossing polygons are not skipped. const inBbox = bboxContainsPoint(entry.bbox, point) || diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts index d774a65..f546e81 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapSelectionTypes.ts @@ -13,6 +13,8 @@ export type Tgu2DMapSelection = status?: TguPlanStatus; /** id режима плана (включения) для polygon-ов слоя planWorks — связь с работой таймлайна. */ modeId?: number; + /** Номер витка (layer "swath") — для карточки полосы обзора. */ + revolution?: number; /** Важность ячейки тепловой карты (layer "heatmap"). */ importance?: number; /** Номер ячейки сетки заданий (layer "heatmap"). */ diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts index 6c139d8..82dd7b4 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/model/mapTypes.ts @@ -34,10 +34,14 @@ export type MapPolygon = { surveyType?: string; /** id режима плана (survey), к которому относится контур — для связи «включение ↔ объект на карте». */ modeId?: number; + /** Номер витка (layer "swath") — для карточки полосы обзора. */ + revolution?: number; /** Важность ячейки тепловой карты (layer "heatmap"): задаёт цвет заливки и показывается в инспекторе. */ importance?: number; /** Номер ячейки сетки заданий (layer "heatmap") — ключ для поиска полной карточки ячейки. */ cellNum?: number; + /** false — контур рисуется, но исключён из hit-теста/выбора (напр. контур заявки на вкладке «Слоты»). */ + pickable?: boolean; zIndex: number; }; diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx index cb66811..e88b425 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.tsx @@ -14,6 +14,7 @@ import { ConstellationTab } from "../constellation/ConstellationTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab"; import { TguRequestsTab } from "../tgu-requests/TguRequestsTab"; +import { TguSlotsTab } from "../tgu-slots/TguSlotsTab"; import { abandonDraft, createDraft, @@ -587,6 +588,12 @@ export function TguPlanningPage() {
)} + {visitedTabs.has("slots") && ( +
+ +
+ )} + {visitedTabs.has("constellation") && (
diff --git a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx index 56cd096..3311db0 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-planning/TguToolbar.tsx @@ -26,6 +26,11 @@ export function TguToolbar({ onApply, onQuickRange }: TguToolbarProps) { + // Глобальный диапазон «С … По» относится только к вкладке «Планы». Остальные вкладки имеют свой + // источник интервала: «Карта» и «Заявки» — фильтр дат в левой панели, «Комплексный план» — свой + // диапазон внутри вкладки, «Редактор» отражает выбранный на «Планах» план (его окно задаётся + // планом), «Группировка» диапазоном не пользуется. Поэтому панель показываем только на «Планах». + const showRange = activeTab === "timeline"; return (
@@ -83,6 +88,15 @@ export function TguToolbar({ > Заявки +
- - - - - - - -
+ + {showRange && ( +
+ + + + + + + +
+ )} {error &&
{error}
} ); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx new file mode 100644 index 0000000..915616e --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotDaySelector.tsx @@ -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(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) { + e.stopPropagation(); + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + }, + onPointerMove(e: React.PointerEvent) { + 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) { + (e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId); + }, + }); + + const onTrackPointerDown = (e: React.PointerEvent) => { + 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 ( +
+
+ Сутки + + {fromDay.label === toDay.label ? fromDay.label : `${fromDay.label}–${toDay.label}`} + + слотов {countInRange} + {!isFullRange && ( + + )} +
+ +
+
+
+ {days.map((day) => ( +
= fromIndex && day.index <= toIndex ? " is-active" : ""}`} + style={{ left: `${pct(day.index)}%` }} + /> + ))} +
+
+
+
+
+ ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx new file mode 100644 index 0000000..0819082 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/SlotPickMenu.tsx @@ -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 ( + + ); +} diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx new file mode 100644 index 0000000..c5433ff --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -0,0 +1,801 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import { + bookRequestSlots, + cancelRequestBooking, + fetchBookedSlotsByRequest, + fetchRequestSlots +} from "../../api/slotsApi"; +import type { RequestMapItem } from "../../model/requestTypes"; +import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"; +import type { TimelineRange } from "../../model/timelineTypes"; +import { DateTime24Field } from "../../components/timeFields"; +import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import type { GeoPoint, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; +import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; +import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes"; +import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector"; +import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; +import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; +import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; +import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Начало локальных суток момента (мс) — для группировки слотов по суткам. */ +function dayStartMs(ms: number): number { + const d = new Date(ms); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** Метка суток ДД.ММ (локально). */ +function dayLabel(ms: number): string { + const d = new Date(ms); + return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +type TguSlotsTabProps = { + /** Диапазон вкладки «Планы» — начальный интервал отбора заявок и расчёта слотов. */ + range: TimelineRange; +}; + +/** Ключ слота: КА + номер слота + цикл — уникален в пределах расчёта и совпадает с ключом брони. */ +function slotKey(slot: { satelliteId: number; slotNumber: number; cycle: number }): string { + return `${slot.satelliteId}:${slot.slotNumber}:${slot.cycle}`; +} + +export function TguSlotsTab({ range }: TguSlotsTabProps) { + // Заявки интервала (показываются на карте, выбираются кликом) и выбранная заявка. + const [requestItems, setRequestItems] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(false); + const [requestsLoaded, setRequestsLoaded] = useState(false); + const [requestsTotal, setRequestsTotal] = useState(0); + const [requestsTruncated, setRequestsTruncated] = useState(false); + const [requestsError, setRequestsError] = useState(); + const [selectedRequestId, setSelectedRequestId] = useState(""); + + // Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»). + const [reqFromValue, setReqFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [reqToValue, setReqToValue] = useState(() => toLocalInputValue(range.toMs)); + + // Интервал и фильтры расчёта слотов выбранной заявки. + const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); + const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); + const [revSign, setRevSign] = useState<"" | RevolutionSign>(""); + const [withCoverage, setWithCoverage] = useState(false); + + // Результаты: слоты-кандидаты и уже забронированные слоты заявки. + const [slots, setSlots] = useState([]); + const [bookedSlots, setBookedSlots] = useState([]); + const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); + + // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта. + const [dayFrom, setDayFrom] = useState(1); + const [dayTo, setDayTo] = useState(1); + + const [calculating, setCalculating] = useState(false); + const [booking, setBooking] = useState(false); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + // Подсветка строки/контура по клику на карте и центрирование на слоте. + const [activeSlotId, setActiveSlotId] = useState(); + const [focusPoint, setFocusPoint] = useState(); + const focusTokenRef = useRef(0); + const [focusToken, setFocusToken] = useState(0); + // Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости. + const listRef = useRef(null); + + // Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки). + const [mapHits, setMapHits] = useState([]); + const [activeHitId, setActiveHitId] = useState(); + + // Меню выбора слота при плотном наложении (несколько слотов под точкой клика). + const [slotPicks, setSlotPicks] = useState([]); + + const hasRequest = !!selectedRequestId; + + const selectedRequest = useMemo( + () => requestItems.find((item) => item.id === selectedRequestId), + [requestItems, selectedRequestId] + ); + + const parsedRange = useMemo( + () => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }), + [fromValue, toValue] + ); + const rangeInvalid = + !Number.isFinite(parsedRange.fromMs) || + !Number.isFinite(parsedRange.toMs) || + parsedRange.fromMs >= parsedRange.toMs; + + // Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте. + const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]); + + // Слот по ключу — чтобы из выбора на карте (id "slot:key"/"booked:key") восстановить полные данные. + const slotByKey = useMemo(() => { + const map = new Map(); + for (const slot of slots) map.set(slotKey(slot), slot); + for (const slot of bookedSlots) if (!map.has(slotKey(slot))) map.set(slotKey(slot), slot); + return map; + }, [slots, bookedSlots]); + + // Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения). + const days = useMemo(() => { + const buckets = new Map(); + for (const slot of slots) { + const ms = parseLocal(slot.tn); + if (!Number.isFinite(ms)) continue; + const start = dayStartMs(ms); + buckets.set(start, (buckets.get(start) ?? 0) + 1); + } + return Array.from(buckets.entries()) + .sort(([a], [b]) => a - b) + .map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) })); + }, [slots]); + + // Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон. + useEffect(() => { + setDayFrom(1); + setDayTo(days.length || 1); + }, [days]); + + const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); + const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); + + // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте. + const filteredSlots = useMemo(() => { + if (days.length === 0) return slots; + const fromMs = days[effDayFrom - 1].dayMs; + const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; + return slots.filter((slot) => { + const ms = parseLocal(slot.tn); + return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; + }); + }, [slots, days, effDayFrom, effDayTo]); + + // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. + const loadRequests = useCallback(async (fromInput: string, toInput: string) => { + const fromMs = parseLocal(fromInput); + const toMs = parseLocal(toInput); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + setRequestsLoading(true); + setRequestsError(undefined); + try { + const result = await fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) }); + setRequestItems(result.items); + setRequestsTotal(result.totalItems); + setRequestsTruncated(result.truncated); + setRequestsLoaded(true); + } catch (caught) { + setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки."); + } finally { + setRequestsLoading(false); + } + }, []); + + // Заявки за диапазон вкладки — грузим на входе и при смене диапазона. + useEffect(() => { + const fromInput = toLocalInputValue(range.fromMs); + const toInput = toLocalInputValue(range.toMs); + setReqFromValue(fromInput); + setReqToValue(toInput); + void loadRequests(fromInput, toInput); + }, [range.fromMs, range.toMs, loadRequests]); + + // Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы. + // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой + // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. + useEffect(() => { + if (!activeSlotId) return; + const container = listRef.current; + const row = container?.querySelector(`[data-slot-id="${activeSlotId}"]`); + if (!container || !row) return; + const cRect = container.getBoundingClientRect(); + const rRect = row.getBoundingClientRect(); + if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна + const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2); + container.scrollBy({ top: delta, behavior: "smooth" }); + }, [activeSlotId]); + + const focusOn = useCallback((point?: GeoPoint) => { + if (!point) return; + setFocusPoint(point); + focusTokenRef.current += 1; + setFocusToken(focusTokenRef.current); + }, []); + + // Загружает брони выбранной заявки (для бейджей и слоя карты). + const loadBooked = useCallback(async (requestId: string) => { + try { + const booked = await fetchBookedSlotsByRequest(requestId); + setBookedSlots(booked); + } catch (caught) { + setBookedSlots([]); + setError(caught instanceof Error ? caught.message : "Не удалось загрузить брони заявки."); + } + }, []); + + // Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони. + useEffect(() => { + setSlots([]); + setSelectedKeys(new Set()); + setBookedSlots([]); + setNotice(undefined); + setError(undefined); + setActiveSlotId(undefined); + if (selectedRequestId) void loadBooked(selectedRequestId); + }, [selectedRequestId, loadBooked]); + + const reqFromMs = parseLocal(reqFromValue); + const reqToMs = parseLocal(reqToValue); + const reqRangeInvalid = + !Number.isFinite(reqFromMs) || !Number.isFinite(reqToMs) || reqFromMs >= reqToMs; + + const applyRequests = () => void loadRequests(reqFromValue, reqToValue); + const setQuickRange = (fromMs: number, toMs: number) => { + const fromInput = toLocalInputValue(fromMs); + const toInput = toLocalInputValue(toMs); + setReqFromValue(fromInput); + setReqToValue(toInput); + void loadRequests(fromInput, toInput); + }; + const quickToday = () => { + const now = new Date(); + const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + setQuickRange(fromMs, fromMs + DAY_MS); + }; + const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS); + const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS); + + const calculate = async () => { + if (!selectedRequestId || rangeInvalid || calculating) return; + setCalculating(true); + setError(undefined); + setNotice(undefined); + try { + const result = await fetchRequestSlots({ + requestId: selectedRequestId, + timeStart: toLocalIso(parsedRange.fromMs), + timeStop: toLocalIso(parsedRange.toMs), + revSign: revSign || undefined, + cov: withCoverage + }); + setSlots(result); + setSelectedKeys(new Set()); + setNotice( + result.length > 0 + ? `Найдено слотов: ${result.length}.` + : "Для выбранного интервала слотов покрытия не найдено." + ); + if (result.length > 0) { + focusOn({ lat: result[0].latitude, lon: result[0].longitude }); + } + } catch (caught) { + setSlots([]); + setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты."); + } finally { + setCalculating(false); + } + }; + + const book = async () => { + if (!selectedRequestId || booking || selectedKeys.size === 0) return; + const toBook = slots + .filter((slot) => selectedKeys.has(slotKey(slot))) + .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); + if (toBook.length === 0) return; + setBooking(true); + setError(undefined); + setNotice(undefined); + try { + const booked = await bookRequestSlots(selectedRequestId, toBook); + // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle + // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле + // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и + // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты + // (с контуром), что вернул POST, не перечитывая by-request. + const bookedSet = new Set(booked.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); + const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot))); + setBookedSlots((prev) => { + const merged = new Map(prev.map((slot) => [slotKey(slot), slot])); + for (const slot of justBooked) merged.set(slotKey(slot), slot); + return Array.from(merged.values()); + }); + setSelectedKeys(new Set()); + setNotice(`Забронировано слотов: ${booked.length}.`); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты."); + } finally { + setBooking(false); + } + }; + + const cancelBooking = async () => { + if (!selectedRequestId || booking || bookedSlots.length === 0) return; + setBooking(true); + setError(undefined); + setNotice(undefined); + try { + const cancelled = await cancelRequestBooking(selectedRequestId); + // Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()). + setBookedSlots([]); + setNotice(`Снято броней: ${cancelled}.`); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось снять бронь."); + } finally { + setBooking(false); + } + }; + + const toggleSlot = (key: string) => { + setSelectedKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // «Выбрать все» работает по видимым (отфильтрованным) слотам. + const allSelected = + filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); + const toggleAll = () => { + setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey))); + }; + + const selectRow = (slot: SlotDTO) => { + const id = `slot:${slotKey(slot)}`; + setActiveSlotId(id); + focusOn({ lat: slot.latitude, lon: slot.longitude }); + }; + + // Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов. + const chooseRequest = useCallback((requestId: string) => { + setSelectedRequestId(requestId); + setMapHits([]); + setActiveHitId(undefined); + }, []); + + // Сброс окна к состоянию «заявка не выбрана, слоты не показаны». + const resetSelection = useCallback(() => { + setSelectedRequestId(""); + setMapHits([]); + setActiveHitId(undefined); + setActiveSlotId(undefined); + }, []); + + // Сцена карты. До выбора заявки — все заявки интервала (для выбора кликом). После выбора — + // контур заявки (слой requests), слоты-кандидаты (swath) и брони (complexPlan). + const scene = useMemo(() => { + const polygons: MapPolygon[] = []; + let z = 0; + + if (!hasRequest) { + for (const item of requestItems) { + if (!item.geometry) continue; + for (const ring of parseWktPolygon(item.geometry)) { + if (ring.length < 3) continue; + polygons.push({ + id: `request:${item.id}`, + name: item.name, + kind: "request", + layer: "requests", + points: ring, + requestId: item.id, + zIndex: z++ + }); + } + } + return { polygons, lines: [], markers: [] }; + } + + if (selectedRequest?.geometry) { + for (const ring of parseWktPolygon(selectedRequest.geometry)) { + if (ring.length < 3) continue; + polygons.push({ + id: `request:${selectedRequest.id}`, + name: selectedRequest.name, + kind: "request", + layer: "requests", + points: ring, + requestId: selectedRequest.id, + // Контур заявки рисуем, но из выбора исключаем — иначе он «съедает» меню выбора слотов + // (как единственный requests-hit под точкой) и любой клик внутри выбирал бы заявку. + pickable: false, + zIndex: z++ + }); + } + } + for (const slot of filteredSlots) { + const key = slotKey(slot); + for (const ring of parseWktPolygon(slot.contour)) { + if (ring.length < 3) continue; + polygons.push({ + id: `slot:${key}`, + name: `КА ${slot.satelliteId} · слот ${slot.slotNumber}`, + kind: bookedKeys.has(key) ? "complexPlan" : "swath", + layer: bookedKeys.has(key) ? "complexPlan" : "swath", + points: ring, + spacecraftId: String(slot.satelliteId), + revolution: slot.revolution, + zIndex: z++ + }); + } + } + // Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно. + const slotKeysShown = new Set(slots.map(slotKey)); + for (const slot of bookedSlots) { + const key = slotKey(slot); + if (slotKeysShown.has(key)) continue; + for (const ring of parseWktPolygon(slot.contour)) { + if (ring.length < 3) continue; + polygons.push({ + id: `booked:${key}`, + name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`, + kind: "complexPlan", + layer: "complexPlan", + points: ring, + spacecraftId: String(slot.satelliteId), + revolution: slot.revolution, + zIndex: z++ + }); + } + } + return { polygons, lines: [], markers: [] }; + }, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys]); + + const layers = useMemo( + () => ({ + tracks: false, + swath: true, + planWorks: false, + complexPlan: true, + heatmap: false, + stations: false, + spacecraftMarkers: false, + requests: true + }), + [] + ); + + // Клик по карте. С выбранной заявкой — подсветка слота (синхрон со строкой). Без заявки — + // открываем инспектор заявки (одиночный клик) для выбора. + const handleObjectSelect = useCallback( + (selection?: Tgu2DMapSelection) => { + if (hasRequest) { + // Одиночное попадание — подсветку строки ведём только по клику на слот; клик по пустому + // месту снимает подсветку. Меню выбора (наложение) при этом закрываем. + setSlotPicks([]); + const id = selection?.id; + if (!selection) setActiveSlotId(undefined); + else if (id && (id.startsWith("slot:") || id.startsWith("booked:"))) setActiveSlotId(id); + return; + } + if (selection && selection.type === "polygon" && selection.layer === "requests") { + setMapHits([selection]); + setActiveHitId(selection.id); + } else { + setMapHits([]); + setActiveHitId(undefined); + } + }, + [hasRequest] + ); + + // Наложение объектов под точкой. С выбранной заявкой — меню выбора слота (плотные наложения); + // без заявки — список заявок в инспекторе с раскрытием нужной. + const handleObjectsAtPoint = useCallback( + (selections: Tgu2DMapSelection[]) => { + if (hasRequest) { + const seen = new Set(); + const picks: SlotPick[] = []; + for (const sel of selections) { + if (sel.type !== "polygon") continue; + if (!sel.id.startsWith("slot:") && !sel.id.startsWith("booked:")) continue; + if (seen.has(sel.id)) continue; + const key = sel.id.slice(sel.id.indexOf(":") + 1); + const slot = slotByKey.get(key); + if (!slot) continue; + seen.add(sel.id); + picks.push({ id: sel.id, slot, booked: bookedKeys.has(key) }); + } + setSlotPicks(picks); + return; + } + const requests = selections.filter((s) => s.type === "polygon" && s.layer === "requests"); + const hits = requests.length > 0 ? requests : selections; + setMapHits(hits); + setActiveHitId(hits[0]?.id); + }, + [hasRequest, slotByKey, bookedKeys] + ); + + // Выбор слота из меню наложения — подсвечиваем строку, но меню оставляем открытым + // (с пометкой активного пункта), чтобы можно было перебрать наложенные слоты; закрыть — крестиком. + const pickSlot = useCallback((id: string) => { + setActiveSlotId(id); + }, []); + + const closeSlotPick = useCallback(() => setSlotPicks([]), []); + + const toggleHit = useCallback((selection: Tgu2DMapSelection) => { + setActiveHitId((current) => (current === selection.id ? undefined : selection.id)); + }, []); + + const closeInspector = useCallback(() => { + setMapHits([]); + setActiveHitId(undefined); + }, []); + + const canCalculate = hasRequest && !rangeInvalid && !calculating; + const overlayMessage = calculating + ? "Расчёт слотов…" + : !hasRequest + ? requestsLoading + ? "Загрузка заявок…" + : undefined + : slots.length === 0 && bookedSlots.length === 0 + ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»." + : undefined; + + return ( +
+
+
+
Слоты
+
Расчёт покрытия и бронирование слотов заявки
+
+
+ Заявка +
+ {selectedRequest ? selectedRequest.name : "—"} +
+
+ + + + + + + +
+ {(slots.length > 0 || bookedSlots.length > 0) && ( +
+ слотов {slots.length} · забронировано {bookedSlots.length} +
+ )} +
+ +
+
+ {hasRequest ? ( + filteredSlots.length === 0 ? ( +
+ {slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."} +
+ ) : ( + + + + + + + + + + + + + + + {filteredSlots.map((slot) => { + const key = slotKey(slot); + const id = `slot:${key}`; + const booked = bookedKeys.has(key); + const isActive = activeSlotId === id; + return ( + selectRow(slot)} + > + + + + + + + + + + ); + })} + +
+ + КАВитокСлот / циклНачалоКонецКренСтатус
event.stopPropagation()}> + toggleSlot(key)} + aria-label="Выбрать слот" + /> + {slot.satelliteId} + {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} + + {slot.slotNumber} / {slot.cycle} + {formatLocalDateTime(parseLocal(slot.tn))}{formatLocalDateTime(parseLocal(slot.tk))}{slot.roll.toFixed(1)}° + {booked ? ( + бронь + ) : ( + {slotStateLabel(slot.state)} + )} +
+ ) + ) : ( +
+
+
Заявки на интервал
+
+ + +
+ + + + +
+ {requestsError && ( +
{requestsError}
+ )} + {requestsLoaded && !requestsError && ( +
+ {requestItems.length === 0 + ? "Заявок не найдено." + : `Загружено: ${requestItems.length} из ${requestsTotal}`} + {requestsTruncated && ( + (показаны первые 5000 — уточните интервал) + )} +
+ )} + {requestItems.length > 0 && ( +
+ OPTICS + RSA + COMBINED +
+ )} +
+
+
Кликните заявку на карте, чтобы выбрать.
+
+ )} +
+ +
+ {overlayMessage &&
{overlayMessage}
} + {hasRequest && ( + + )} + {hasRequest && days.length > 0 && ( + + )} + + {!hasRequest && mapHits.length > 0 && ( + + )} + {hasRequest && slotPicks.length > 0 && ( + + )} +
+
+ +
+ {notice &&
{notice}
} + {error &&
{error}
} +
+
+ ); +} + +function slotStateLabel(state: string): string { + switch (state) { + case "AVAILABLE": + return "свободен"; + case "BOOKED": + return "бронь"; + case "OCCUPIED": + return "занят"; + default: + return state || "—"; + } +} diff --git a/services/pcp-tgu-ui-service/src/model/slotTypes.ts b/services/pcp-tgu-ui-service/src/model/slotTypes.ts new file mode 100644 index 0000000..ebecf33 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/model/slotTypes.ts @@ -0,0 +1,55 @@ +// Типы слотов покрытия и бронирования. Зеркалят DTO из pcp-types-lib +// (space.nstart.pcp.pcp_types_lib.dto.requests.slots), которые отдаёт slots-service. + +/** Признак ветви витка (восходящая/нисходящая). */ +export type RevolutionSign = "ASC" | "DESC"; + +/** Статус слота из расчёта покрытия. */ +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; +}; diff --git a/services/pcp-tgu-ui-service/src/model/timelineTypes.ts b/services/pcp-tgu-ui-service/src/model/timelineTypes.ts index d0c0cbe..9d191e9 100644 --- a/services/pcp-tgu-ui-service/src/model/timelineTypes.ts +++ b/services/pcp-tgu-ui-service/src/model/timelineTypes.ts @@ -22,7 +22,14 @@ export type TimelineRange = { toMs: number; }; -export type TguActiveTab = "timeline" | "map" | "complex" | "editor" | "requests" | "constellation"; +export type TguActiveTab = + | "timeline" + | "map" + | "complex" + | "editor" + | "requests" + | "slots" + | "constellation"; export type TimelineRow = { spacecraftId: string; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index 0d9a2b0..ddd2b77 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -23,6 +23,14 @@ 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 { display: flex; align-items: center; @@ -906,6 +914,215 @@ 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; + overflow: auto; + border-right: 1px solid var(--line); + background: var(--bg-panel); +} + +.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 { position: relative; min-width: 0; @@ -1052,6 +1269,75 @@ background: var(--bg-panel); color: var(--text); 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 { From 345529cde05772fed24ca125fbfb87a5ff4598e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Thu, 11 Jun 2026 14:00:12 +0300 Subject: [PATCH 09/11] =?UTF-8?q?chore:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D0=BA=D1=83=D1=89?= =?UTF-8?q?=D0=B8=D1=85=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20(deploy=20local-bundle/local-jar,=20request-service,?= =?UTF-8?q?=20slots=20UI=20markedObjectIds)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Безопасный коммит незакоммиченной работы перед реализацией бронирования слотов. Вне коммита оставлены: package-lock.json (пустой, мусор), requests.json (scratch-данные). --- .gitignore | 2 + create-service-dbs.sh | 2 - deploy/local-bundle/.env.example | 25 +- deploy/local-bundle/README-local.md | 129 ++++- deploy/local-bundle/build-local-bundle.sh | 18 +- deploy/local-bundle/docker-compose.yml | 36 +- deploy/local-bundle/run-local.sh | 199 +++++++ deploy/local-bundle/run-service.sh | 242 ++++++++ deploy/local-bundle/scripts/import-images.sh | 13 +- deploy/local-bundle/scripts/init-db.sh | 59 +- deploy/local-bundle/scripts/start.sh | 42 +- .../services/pcp-ballistics-service.sh | 5 + .../services/pcp-complex-mission-service.sh | 5 + .../services/pcp-coverage-scheme-service.sh | 5 + .../services/pcp-dynamic-plan-service.sh | 5 + .../services/pcp-mission-planing-service.sh | 5 + .../services/pcp-request-service.sh | 5 + .../services/pcp-route-processing-service.sh | 5 + .../services/pcp-satellite-catalog-service.sh | 5 + .../services/pcp-srpring-boot-admin-server.sh | 5 + .../services/pcp-stations-service.sh | 5 + .../local-bundle/services/pcp-tgu-service.sh | 5 + .../local-bundle/services/pcp-ui-service.sh | 5 + deploy/local-bundle/services/slots-service.sh | 5 + .../services/spring-cloud-config-server.sh | 5 + deploy/local-jar/README.md | 79 +++ deploy/local-jar/run-services.sh | 538 ++++++++++++++++++ docs/аудит-проекта-2026-06-06.md | 173 ++++++ docs/бэклог-pcp-tgu-ops-ui.md | 5 + .../requestservice/dto/RequestApiDtos.kt | 5 +- .../requestservice/service/RequestService.kt | 2 +- .../service/RequestServiceTest.kt | 19 +- .../pcp-route-processing-service/Dockerfile | 9 + .../src/features/tgu-map-2d/Tgu2DMapView.tsx | 29 + .../src/features/tgu-slots/TguSlotsTab.tsx | 7 + .../src/styles/tgu-planning.css | 8 + 36 files changed, 1674 insertions(+), 37 deletions(-) mode change 100644 => 100755 create-service-dbs.sh create mode 100755 deploy/local-bundle/run-local.sh create mode 100755 deploy/local-bundle/run-service.sh create mode 100755 deploy/local-bundle/services/pcp-ballistics-service.sh create mode 100755 deploy/local-bundle/services/pcp-complex-mission-service.sh create mode 100755 deploy/local-bundle/services/pcp-coverage-scheme-service.sh create mode 100755 deploy/local-bundle/services/pcp-dynamic-plan-service.sh create mode 100755 deploy/local-bundle/services/pcp-mission-planing-service.sh create mode 100755 deploy/local-bundle/services/pcp-request-service.sh create mode 100755 deploy/local-bundle/services/pcp-route-processing-service.sh create mode 100755 deploy/local-bundle/services/pcp-satellite-catalog-service.sh create mode 100755 deploy/local-bundle/services/pcp-srpring-boot-admin-server.sh create mode 100755 deploy/local-bundle/services/pcp-stations-service.sh create mode 100755 deploy/local-bundle/services/pcp-tgu-service.sh create mode 100755 deploy/local-bundle/services/pcp-ui-service.sh create mode 100755 deploy/local-bundle/services/slots-service.sh create mode 100755 deploy/local-bundle/services/spring-cloud-config-server.sh create mode 100644 deploy/local-jar/README.md create mode 100755 deploy/local-jar/run-services.sh create mode 100644 docs/аудит-проекта-2026-06-06.md create mode 100644 services/pcp-route-processing-service/Dockerfile diff --git a/.gitignore b/.gitignore index 39d4a73..9f819b2 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ out/ /pcp_zip.zip /pcp-chatgpt-context*.zip /deploy/local-bundle/build/ +/deploy/local-bundle/.env /deploy/local-bundle/pcp-local-bundle-*.tar.gz ### Node / Vite ### @@ -69,3 +70,4 @@ out/ /services/pcp-tgu-ops-ui/yarn-debug.log* /services/pcp-tgu-ops-ui/yarn-error.log* /services/pcp-tgu-ops-ui/pnpm-debug.log* +.gstack/ diff --git a/create-service-dbs.sh b/create-service-dbs.sh old mode 100644 new mode 100755 index 33534d6..fa446cb --- a/create-service-dbs.sh +++ b/create-service-dbs.sh @@ -1,4 +1,3 @@ -cat > create-service-dbs.sh <<'BASH' #!/usr/bin/env bash set -euo pipefail @@ -124,4 +123,3 @@ done echo echo "All requested databases processed." -BASH diff --git a/deploy/local-bundle/.env.example b/deploy/local-bundle/.env.example index a93158d..6ad9046 100644 --- a/deploy/local-bundle/.env.example +++ b/deploy/local-bundle/.env.example @@ -1,5 +1,18 @@ 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 POSTGRES_IMAGE=postgis/postgis:16-3.4 @@ -8,14 +21,24 @@ KAFKA_IMAGE=apache/kafka:3.7.2 POSTGRES_USER=postgres POSTGRES_PASSWORD=password POSTGRES_PORT=5432 +PCP_POSTGRES_HOST=postgres +PCP_POSTGRES_PORT=5432 +EXTERNAL_POSTGRES_CONTAINER= 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 PCP_ADMIN_PORT=7000 -PCP_TLE_MONITORING_PORT=7001 PCP_COMPLEX_MISSION_PORT=7002 PCP_BALLISTICS_PORT=7003 +PCP_ROUTE_PROCESSING_PORT=7004 PCP_REQUEST_PORT=7005 PCP_SLOTS_PORT=7006 PCP_UI_PORT=7008 diff --git a/deploy/local-bundle/README-local.md b/deploy/local-bundle/README-local.md index fc8b1ae..1840177 100644 --- a/deploy/local-bundle/README-local.md +++ b/deploy/local-bundle/README-local.md @@ -2,6 +2,9 @@ Локальный bundle предназначен для запуска PCP на машине с Docker без GitLab Config Repo, Kubernetes и Helm. +В bundle входят backend-сервисы PCP, кроме `tle-monitoring-service`; отдельный frontend +`pcp-tgu-ops-ui` также не включается. + ## Состав - `docker-compose.yml` - локальная схема запуска. @@ -15,6 +18,72 @@ - `scripts/stop.sh` - остановка compose stack. - `scripts/status.sh` - статус контейнеров. +## Локальный запуск без архива + +Для запуска на той же машине, где собираются images, архив и `docker save` не нужны. +Используйте один скрипт: + +```bash +./deploy/local-bundle/run-local.sh start +``` + +Скрипт: + +- создаёт `.env` из `.env.example`, если его ещё нет; +- собирает `bootJar` в Gradle offline mode; +- собирает локальные Docker images `pcp/:`; +- запускает `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:`; +- `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-.tar.gz Скрипт сборки: - собирает `bootJar` для разворачиваемых сервисов; +- запускает Gradle в offline mode по умолчанию; - собирает Docker images сервисов; - сохраняет PCP service images в один `images/pcp-services.tar`; - по умолчанию не сохраняет публичные infra images, чтобы не раздувать архив; @@ -61,6 +131,41 @@ KOTLIN_DAEMON_JVMARGS="-Xmx3g -XX:MaxMetaspaceSize=768m" \ ./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- Проверить `.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: ```bash @@ -109,9 +234,9 @@ cd pcp-local-bundle- - Config Server: `http://localhost:38888` - Spring Boot Admin: `http://localhost:7000` -- TLE Monitoring: `http://localhost:7001` - Complex Mission: `http://localhost:7002` - Ballistics: `http://localhost:7003` +- Route Processing: `http://localhost:7004` - Request Service: `http://localhost:7005` - Slots Service: `http://localhost:7006` - UI: `http://localhost:7008` @@ -178,13 +303,13 @@ SPRING_CLOUD_CONFIG_LABEL= - `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` Таблицы, схемы и seed data вручную не создаются. Схемы создаются существующими Flyway migrations внутри сервисов при старте приложений. diff --git a/deploy/local-bundle/build-local-bundle.sh b/deploy/local-bundle/build-local-bundle.sh index 974578c..8455e27 100755 --- a/deploy/local-bundle/build-local-bundle.sh +++ b/deploy/local-bundle/build-local-bundle.sh @@ -26,6 +26,7 @@ service_names=( pcp-satellite-catalog-service pcp-ballistics-service pcp-request-service + pcp-route-processing-service slots-service pcp-complex-mission-service pcp-mission-planing-service @@ -33,13 +34,14 @@ service_names=( pcp-dynamic-plan-service pcp-stations-service pcp-ui-service - tle-monitoring-service pcp-tgu-service ) 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}" +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}" gradle_common_args=( @@ -49,6 +51,10 @@ gradle_common_args=( "-Pkotlin.daemon.jvmargs=$kotlin_daemon_jvmargs" ) +if [[ "$gradle_offline" == "true" ]]; then + gradle_common_args+=("--offline") +fi + infra_images=( "${POSTGRES_IMAGE:-postgis/postgis:16-3.4}" "${KAFKA_IMAGE:-apache/kafka:3.7.2}" @@ -117,9 +123,11 @@ 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" +echo "Gradle offline mode: $gradle_offline" +echo "Local bundle runtime image: $runtime_image" for service_name in "${service_names[@]}"; do 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 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" 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") done diff --git a/deploy/local-bundle/docker-compose.yml b/deploy/local-bundle/docker-compose.yml index dd52411..8a5437d 100644 --- a/deploy/local-bundle/docker-compose.yml +++ b/deploy/local-bundle/docker-compose.yml @@ -8,9 +8,17 @@ x-pcp-service-env: &pcp-service-env SPRING_CLOUD_CONFIG_LABEL: "" SPRING_KAFKA_ADMIN_AUTO_CREATE: "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 restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: spring-cloud-config-server: condition: service_started @@ -64,11 +72,8 @@ services: spring-cloud-config-server: image: pcp/spring-cloud-config-server:${PCP_IMAGE_TAG:-local} restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - kafka: - condition: service_started + extra_hosts: + - "host.docker.internal:host-gateway" environment: SPRING_PROFILES_ACTIVE: local CONFIG_NATIVE_SEARCH_LOCATIONS: file:/config-repo @@ -86,14 +91,6 @@ services: ports: - "${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-service-common image: pcp/pcp-complex-mission-service:${PCP_IMAGE_TAG:-local} @@ -118,6 +115,14 @@ services: ports: - "${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: <<: *pcp-service-common image: pcp/slots-service:${PCP_IMAGE_TAG:-local} @@ -193,3 +198,8 @@ services: volumes: postgres-data: kafka-data: + +networks: + default: + name: ${PCP_DOCKER_NETWORK:-pcp-local_default} + external: ${PCP_DOCKER_NETWORK_EXTERNAL:-false} diff --git a/deploy/local-bundle/run-local.sh b/deploy/local-bundle/run-local.sh new file mode 100755 index 0000000..5771eaa --- /dev/null +++ b/deploy/local-bundle/run-local.sh @@ -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 diff --git a/deploy/local-bundle/run-service.sh b/deploy/local-bundle/run-service.sh new file mode 100755 index 0000000..684999f --- /dev/null +++ b/deploy/local-bundle/run-service.sh @@ -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 diff --git a/deploy/local-bundle/scripts/import-images.sh b/deploy/local-bundle/scripts/import-images.sh index f48ad24..25fb0cc 100755 --- a/deploy/local-bundle/scripts/import-images.sh +++ b/deploy/local-bundle/scripts/import-images.sh @@ -25,6 +25,10 @@ ensure_env_file() { } ensure_env_file +set -a +# shellcheck disable=SC1091 +source "$bundle_dir/.env" +set +a if [[ ! -d "$images_dir" ]]; then echo "Images directory not found: $images_dir" >&2 @@ -49,7 +53,12 @@ if [[ -f "$images_dir/public-images.txt" ]]; then fi cd "$bundle_dir" -echo "Starting postgres, kafka and spring-cloud-config-server..." -compose up -d postgres kafka spring-cloud-config-server +if [[ "${USE_EXTERNAL_INFRA:-false}" == "true" ]]; then + 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." diff --git a/deploy/local-bundle/scripts/init-db.sh b/deploy/local-bundle/scripts/init-db.sh index 1fa3d8f..cae5684 100755 --- a/deploy/local-bundle/scripts/init-db.sh +++ b/deploy/local-bundle/scripts/init-db.sh @@ -43,6 +43,26 @@ wait_for_postgres() { 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() { local db_name="$1" local exists @@ -61,16 +81,35 @@ create_database_if_missing() { 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=( 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 ) @@ -78,12 +117,20 @@ ensure_env_file load_env cd "$bundle_dir" -compose up -d postgres -wait_for_postgres +if [[ "${USE_EXTERNAL_INFRA:-false}" == "true" ]]; then + wait_for_external_postgres -for db_name in "${databases[@]}"; do - create_database_if_missing "$db_name" -done + for db_name in "${databases[@]}"; do + create_external_database_if_missing "$db_name" + done +else + compose up -d postgres + wait_for_postgres + + for db_name in "${databases[@]}"; do + create_database_if_missing "$db_name" + done +fi echo "Database initialization completed. Tables are left to service Flyway migrations." echo "No schema objects or seed data were created by this script." diff --git a/deploy/local-bundle/scripts/start.sh b/deploy/local-bundle/scripts/start.sh index 32fe041..f8a24e5 100755 --- a/deploy/local-bundle/scripts/start.sh +++ b/deploy/local-bundle/scripts/start.sh @@ -72,11 +72,34 @@ wait_for_kafka() { 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=( 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 @@ -84,7 +107,6 @@ app_services=( pcp-dynamic-plan-service pcp-stations-service pcp-ui-service - tle-monitoring-service pcp-tgu-service ) @@ -93,9 +115,16 @@ load_env cd "$bundle_dir" -echo "Starting infrastructure..." -compose up -d postgres kafka -wait_for_kafka +if [[ "${USE_EXTERNAL_INFRA:-false}" == "true" ]]; then + echo "Using external infrastructure. Skipping local postgres/kafka startup." + 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 "$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" 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." compose ps diff --git a/deploy/local-bundle/services/pcp-ballistics-service.sh b/deploy/local-bundle/services/pcp-ballistics-service.sh new file mode 100755 index 0000000..cf0366e --- /dev/null +++ b/deploy/local-bundle/services/pcp-ballistics-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-complex-mission-service.sh b/deploy/local-bundle/services/pcp-complex-mission-service.sh new file mode 100755 index 0000000..5e1c1fc --- /dev/null +++ b/deploy/local-bundle/services/pcp-complex-mission-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-coverage-scheme-service.sh b/deploy/local-bundle/services/pcp-coverage-scheme-service.sh new file mode 100755 index 0000000..692959f --- /dev/null +++ b/deploy/local-bundle/services/pcp-coverage-scheme-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-dynamic-plan-service.sh b/deploy/local-bundle/services/pcp-dynamic-plan-service.sh new file mode 100755 index 0000000..ede4158 --- /dev/null +++ b/deploy/local-bundle/services/pcp-dynamic-plan-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-mission-planing-service.sh b/deploy/local-bundle/services/pcp-mission-planing-service.sh new file mode 100755 index 0000000..b9ea620 --- /dev/null +++ b/deploy/local-bundle/services/pcp-mission-planing-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-request-service.sh b/deploy/local-bundle/services/pcp-request-service.sh new file mode 100755 index 0000000..39c1680 --- /dev/null +++ b/deploy/local-bundle/services/pcp-request-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-route-processing-service.sh b/deploy/local-bundle/services/pcp-route-processing-service.sh new file mode 100755 index 0000000..82cd037 --- /dev/null +++ b/deploy/local-bundle/services/pcp-route-processing-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-satellite-catalog-service.sh b/deploy/local-bundle/services/pcp-satellite-catalog-service.sh new file mode 100755 index 0000000..3620854 --- /dev/null +++ b/deploy/local-bundle/services/pcp-satellite-catalog-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-srpring-boot-admin-server.sh b/deploy/local-bundle/services/pcp-srpring-boot-admin-server.sh new file mode 100755 index 0000000..c2310d5 --- /dev/null +++ b/deploy/local-bundle/services/pcp-srpring-boot-admin-server.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-stations-service.sh b/deploy/local-bundle/services/pcp-stations-service.sh new file mode 100755 index 0000000..c79d12f --- /dev/null +++ b/deploy/local-bundle/services/pcp-stations-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-tgu-service.sh b/deploy/local-bundle/services/pcp-tgu-service.sh new file mode 100755 index 0000000..a179740 --- /dev/null +++ b/deploy/local-bundle/services/pcp-tgu-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/pcp-ui-service.sh b/deploy/local-bundle/services/pcp-ui-service.sh new file mode 100755 index 0000000..5a3c3ab --- /dev/null +++ b/deploy/local-bundle/services/pcp-ui-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/slots-service.sh b/deploy/local-bundle/services/slots-service.sh new file mode 100755 index 0000000..d3ef6d2 --- /dev/null +++ b/deploy/local-bundle/services/slots-service.sh @@ -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 "$@" diff --git a/deploy/local-bundle/services/spring-cloud-config-server.sh b/deploy/local-bundle/services/spring-cloud-config-server.sh new file mode 100755 index 0000000..1fa0025 --- /dev/null +++ b/deploy/local-bundle/services/spring-cloud-config-server.sh @@ -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 "$@" diff --git a/deploy/local-jar/README.md b/deploy/local-jar/README.md new file mode 100644 index 0000000..83dc930 --- /dev/null +++ b/deploy/local-jar/README.md @@ -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:/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 +``` diff --git a/deploy/local-jar/run-services.sh b/deploy/local-jar/run-services.sh new file mode 100755 index 0000000..d602f5f --- /dev/null +++ b/deploy/local-jar/run-services.sh @@ -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 diff --git a/docs/аудит-проекта-2026-06-06.md b/docs/аудит-проекта-2026-06-06.md new file mode 100644 index 0000000..3062d0b --- /dev/null +++ b/docs/аудит-проекта-2026-06-06.md @@ -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()` — остальные клиенты переписывают то же руками. + +Рекомендация: вынести единый `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** (пакеты/опечатки) — отдельными задачами из-за широкого охвата. diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index acb036f..5d65995 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -38,6 +38,11 @@ _Обновлено: 2026-06-11_ ### Бэкенд / инфраструктура - [ ] **#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» точки трассы с другим номером выпадают и пропадает орбитальный контекст. Свести нумерацию витков к единому источнику (или маппинг на границе), чтобы трасса и маршрут согласовывались по витку. diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt index efdddf1..16b5a5a 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/dto/RequestApiDtos.kt @@ -67,7 +67,10 @@ data class CreateRequestDto( */ @field:DecimalMin(value = "0", inclusive = false) @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 diff --git a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt index e420f55..cee9cbc 100644 --- a/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt +++ b/services/pcp-request-service/src/main/kotlin/org/nstart/dep265/requestservice/service/RequestService.kt @@ -81,7 +81,7 @@ class RequestService( remainingArea = geometryArea, // Порог завершения по покрытию приходит из API (по умолчанию 100%); это прогресс съёмки, // в отличие от importance — важности заявки для карты приоритетов ячеек. - coverageRequiredPercent = request.coverageRequiredPercent, + coverageRequiredPercent = request.coverageRequiredPercent ?: 100.0, importance = importance, beginDateTime = request.beginDateTime, endDateTime = request.endDateTime, diff --git a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt index 4b95e86..4c330ac 100644 --- a/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt +++ b/services/pcp-request-service/src/test/kotlin/org/nstart/dep265/requestservice/service/RequestServiceTest.kt @@ -112,6 +112,23 @@ class RequestServiceTest { assertEquals(80.0, persistedRequests.single().coverageRequiredPercent) } + @Test + fun `create request coalesces null coverage required percent to default`() { + val persistedRequests = mutableListOf() + doReturn(false) + .`when`(requestRepository) + .existsById(REQUEST_ID) + doAnswer { invocation -> + persistedRequests += invocation.getArgument(0) + null + }.`when`(entityManager).persist(any(RequestEntity::class.java)) + + // Явный null (клиент прислал coverageRequiredPercent: null) трактуем как «взять дефолт». + service.createRequest(createRequestDto(coverageRequiredPercent = null)) + + assertEquals(100.0, persistedRequests.single().coverageRequiredPercent) + } + @Test fun `get request by id returns name and importance`() { val request = existingRequest() @@ -296,7 +313,7 @@ class RequestServiceTest { verify(requestCellRepository).deleteByRequestId(REQUEST_ID) } - private fun createRequestDto(coverageRequiredPercent: Double = 100.0): CreateRequestDto { + private fun createRequestDto(coverageRequiredPercent: Double? = 100.0): CreateRequestDto { return CreateRequestDto( id = REQUEST_ID, name = REQUEST_NAME, diff --git a/services/pcp-route-processing-service/Dockerfile b/services/pcp-route-processing-service/Dockerfile new file mode 100644 index 0000000..6fecd3c --- /dev/null +++ b/services/pcp-route-processing-service/Dockerfile @@ -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"] diff --git a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx index b0c30e5..0cfbf2d 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-map-2d/Tgu2DMapView.tsx @@ -24,6 +24,8 @@ type Tgu2DMapViewProps = { selectedRequestPolygonId?: string; /** Внешняя подсветка объекта (контур съёмки или линия сброса) по его id — «как будто кликнули на карте». */ selectedObjectId?: string; + /** Отмеченные объекты (постоянная подсветка независимо от выбора) — напр. слоты, отобранные под бронь. */ + markedObjectIds?: ReadonlySet; /** Точка, на которую центрировать карту; перецентрирование срабатывает при смене focusToken. */ focusPoint?: GeoPoint; focusToken?: number; @@ -84,6 +86,7 @@ export function Tgu2DMapView({ hoveredRequestPolygonId, selectedRequestPolygonId, selectedObjectId, + markedObjectIds, focusPoint, focusToken, enableObjectChooser = false, @@ -529,6 +532,14 @@ export function Tgu2DMapView({ [hoveredRequestPolygonId, activePolygons] ); 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]); // Накопленное расстояние (км) от первой вершины линейки до каждой — по дуге большого круга. @@ -563,6 +574,24 @@ export function Tgu2DMapView({ > { +export async function fetchRequestSlots( + params: RequestSlotsParams, + signal?: AbortSignal, +): Promise { const query = new URLSearchParams() query.set('requestId', params.requestId) query.set('timeStart', params.timeStart) @@ -38,10 +44,12 @@ export async function fetchRequestSlots(params: RequestSlotsParams): Promise query.append('satellites', String(id))) const response = await apiFetch(`${BASE}/request-cover?${query}`, { headers: { Accept: 'application/json' }, + signal, }) if (!response.ok) { diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx index 0a8f29c..46f14fb 100644 --- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { bookRequestSlots, @@ -7,7 +8,13 @@ import { fetchRequestSlots } from "../../api/slotsApi"; import type { RequestMapItem } from "../../model/requestTypes"; -import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"; +import type { + BookedSlotDTO, + RevolutionSign, + SlotBookingRequest, + SlotCoverageStrategy, + SlotDTO +} from "../../model/slotTypes"; import type { TimelineRange } from "../../model/timelineTypes"; import { DateTime24Field } from "../../components/timeFields"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; @@ -19,9 +26,19 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; +import { chunk, selectionAfterCheck } from "./slotSelection"; const DAY_MS = 24 * 60 * 60 * 1000; +/** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */ +const BOOK_CHUNK = 200; +/** Выше этого числа слотов бронь требует подтверждения. */ +const BOOK_CONFIRM_THRESHOLD = 500; +/** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */ +const MAP_HINT_THRESHOLD = 800; +/** Оценка высоты строки таблицы (px) для виртуализатора. */ +const ROW_HEIGHT = 34; + /** Начало локальных суток момента (мс) — для группировки слотов по суткам. */ function dayStartMs(ms: number): number { const d = new Date(ms); @@ -64,6 +81,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); const [revSign, setRevSign] = useState<"" | RevolutionSign>(""); const [withCoverage, setWithCoverage] = useState(false); + // Стратегия автоформирования покрытия (кнопка «Автоподбор»). + const [strategy, setStrategy] = useState("GREEDY"); // Результаты: слоты-кандидаты и уже забронированные слоты заявки. const [slots, setSlots] = useState([]); @@ -75,17 +94,29 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const [dayTo, setDayTo] = useState(1); const [calculating, setCalculating] = useState(false); + const [autoFilling, setAutoFilling] = useState(false); const [booking, setBooking] = useState(false); + // Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь). + const [bookProgress, setBookProgress] = useState(); + const [pendingBook, setPendingBook] = useState(null); const [error, setError] = useState(); const [notice, setNotice] = useState(); + // Кэш распарсенных WKT-контуров по ключу слота — парсим контур один раз, а не на каждый + // ререндер сцены (критично на тысячах слотов). Чистится при смене заявки. + const wktCacheRef = useRef(new Map()); + // Контроллер отмены текущего расчёта/автоподбора — прерывается при повторе или смене заявки. + const abortRef = useRef(null); + // Якорь для выбора диапазона по Shift+клику (индекс в filteredSlots). + const anchorIndexRef = useRef(null); + // Подсветка строки/контура по клику на карте и центрирование на слоте. const [activeSlotId, setActiveSlotId] = useState(); const [focusPoint, setFocusPoint] = useState(); const focusTokenRef = useRef(0); const [focusToken, setFocusToken] = useState(0); - // Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости. - const listRef = useRef(null); + // Скролл-контейнер списка слотов — служит scrollElement для виртуализатора. + const listRef = useRef(null); // Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки). const [mapHits, setMapHits] = useState([]); @@ -161,6 +192,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }); }, [slots, days, effDayFrom, effDayTo]); + // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — + // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). + const rowVirtualizer = useVirtualizer({ + count: filteredSlots.length, + getScrollElement: () => listRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 12 + }); + // Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра. const loadRequests = useCallback(async (fromInput: string, toInput: string) => { const fromMs = parseLocal(fromInput); @@ -194,16 +234,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { // Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой // скролл-предок или счесть строку «достаточно близкой» и не сдвинуться. useEffect(() => { - if (!activeSlotId) return; - const container = listRef.current; - const row = container?.querySelector(`[data-slot-id="${activeSlotId}"]`); - if (!container || !row) return; - const cRect = container.getBoundingClientRect(); - const rRect = row.getBoundingClientRect(); - if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна - const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2); - container.scrollBy({ top: delta, behavior: "smooth" }); - }, [activeSlotId]); + if (!activeSlotId || !activeSlotId.startsWith("slot:")) return; + const key = activeSlotId.slice("slot:".length); + const index = filteredSlots.findIndex((slot) => slotKey(slot) === key); + if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" }); + }, [activeSlotId, filteredSlots, rowVirtualizer]); const focusOn = useCallback((point?: GeoPoint) => { if (!point) return; @@ -212,6 +247,17 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setFocusToken(focusTokenRef.current); }, []); + // Кольца контура слота по ключу — из кэша, парсим WKT не более одного раза на слот. + const ringsFor = useCallback((key: string, contour: string): GeoPoint[][] => { + const cache = wktCacheRef.current; + let rings = cache.get(key); + if (rings === undefined) { + rings = parseWktPolygon(contour); + cache.set(key, rings); + } + return rings; + }, []); + // Загружает брони выбранной заявки (для бейджей и слоя карты). const loadBooked = useCallback(async (requestId: string) => { try { @@ -223,11 +269,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { } }, []); - // Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони. + // Смена заявки — отменяем текущий расчёт, чистим кэш контуров и результаты, + // подтягиваем уже существующие брони. useEffect(() => { + abortRef.current?.abort(); + wktCacheRef.current.clear(); + anchorIndexRef.current = null; setSlots([]); setSelectedKeys(new Set()); setBookedSlots([]); + setPendingBook(null); setNotice(undefined); setError(undefined); setActiveSlotId(undefined); @@ -255,54 +306,92 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS); const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS); - const calculate = async () => { - if (!selectedRequestId || rangeInvalid || calculating) return; - setCalculating(true); + // Общий расчёт слотов. Без стратегии — все слоты-кандидаты («Рассчитать»). Со стратегией — + // автоформирование покрытия: бэк отдаёт достаточный набор, кладём его в преднабор брони. + const runCover = async (coverageStrategy?: SlotCoverageStrategy) => { + if (!selectedRequestId || rangeInvalid) return; + const auto = !!coverageStrategy; + if (auto ? autoFilling : calculating) return; + // Прерываем предыдущий расчёт, если он ещё идёт. + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + anchorIndexRef.current = null; + if (auto) setAutoFilling(true); + else setCalculating(true); setError(undefined); setNotice(undefined); try { - const result = await fetchRequestSlots({ - requestId: selectedRequestId, - timeStart: toLocalIso(parsedRange.fromMs), - timeStop: toLocalIso(parsedRange.toMs), - revSign: revSign || undefined, - cov: withCoverage - }); - setSlots(result); - setSelectedKeys(new Set()); - setNotice( - result.length > 0 - ? `Найдено слотов: ${result.length}.` - : "Для выбранного интервала слотов покрытия не найдено." + const result = await fetchRequestSlots( + { + requestId: selectedRequestId, + timeStart: toLocalIso(parsedRange.fromMs), + timeStop: toLocalIso(parsedRange.toMs), + revSign: revSign || undefined, + cov: withCoverage, + coverageStrategy + }, + ctrl.signal ); + if (ctrl.signal.aborted) return; + setSlots(result); + if (auto) { + // Автонабор → преднабор брони: предлагаем оператору весь сформированный набор. + setSelectedKeys(new Set(result.map(slotKey))); + setNotice( + result.length > 0 + ? `Автоподбор (${strategyLabel(coverageStrategy!)}): предложено ${result.length} слотов — проверьте и забронируйте.` + : "Покрытие для интервала не сформировано." + ); + } else { + setSelectedKeys(new Set()); + setNotice( + result.length > 0 + ? `Найдено слотов: ${result.length}.` + : "Для выбранного интервала слотов покрытия не найдено." + ); + } if (result.length > 0) { focusOn({ lat: result[0].latitude, lon: result[0].longitude }); } } catch (caught) { + // Отмена (повтор/смена заявки) — это не ошибка, молча выходим. + if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return; setSlots([]); setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты."); } finally { - setCalculating(false); + if (abortRef.current === ctrl) abortRef.current = null; + if (auto) setAutoFilling(false); + else setCalculating(false); } }; - const book = async () => { - if (!selectedRequestId || booking || selectedKeys.size === 0) return; - const toBook = slots - .filter((slot) => selectedKeys.has(slotKey(slot))) - .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); - if (toBook.length === 0) return; + const calculate = () => void runCover(); + const autoCover = () => void runCover(strategy); + const cancelCalculation = () => abortRef.current?.abort(); + + // Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор + // бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс. + const performBook = async (toBook: SlotBookingRequest[]) => { setBooking(true); setError(undefined); setNotice(undefined); + setBookProgress(toBook.length > BOOK_CHUNK ? `0/${toBook.length}` : undefined); try { - const booked = await bookRequestSlots(selectedRequestId, toBook); + const collected: BookedSlotDTO[] = []; + let done = 0; + for (const part of chunk(toBook, BOOK_CHUNK)) { + const booked = await bookRequestSlots(selectedRequestId, part); + collected.push(...booked); + done += part.length; + if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`); + } // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты // (с контуром), что вернул POST, не перечитывая by-request. - const bookedSet = new Set(booked.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); + const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot))); setBookedSlots((prev) => { const merged = new Map(prev.map((slot) => [slotKey(slot), slot])); @@ -310,14 +399,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { return Array.from(merged.values()); }); setSelectedKeys(new Set()); - setNotice(`Забронировано слотов: ${booked.length}.`); + setNotice(`Забронировано слотов: ${collected.length}.`); } catch (caught) { setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты."); } finally { setBooking(false); + setBookProgress(undefined); } }; + const collectSelected = (): SlotBookingRequest[] => + slots + .filter((slot) => selectedKeys.has(slotKey(slot))) + .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); + + const book = () => { + if (!selectedRequestId || booking || selectedKeys.size === 0) return; + const toBook = collectSelected(); + if (toBook.length === 0) return; + // Крупная бронь — сначала подтверждение, потом чанкованный POST. + if (toBook.length > BOOK_CONFIRM_THRESHOLD) { + setPendingBook(toBook); + return; + } + void performBook(toBook); + }; + + const confirmPendingBook = () => { + const toBook = pendingBook; + setPendingBook(null); + if (toBook) void performBook(toBook); + }; + const cancelPendingBook = () => setPendingBook(null); + const cancelBooking = async () => { if (!selectedRequestId || booking || bookedSlots.length === 0) return; setBooking(true); @@ -335,21 +449,40 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { } }; - const toggleSlot = (key: string) => { - setSelectedKeys((current) => { - const next = new Set(current); - if (next.has(key)) next.delete(key); - else next.add(key); - return next; - }); + // Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до + // текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки. + const handleSlotCheck = (index: number, shiftKey: boolean) => { + setSelectedKeys((current) => + selectionAfterCheck(current, (i) => slotKey(filteredSlots[i]), index, shiftKey, anchorIndexRef.current) + ); + anchorIndexRef.current = index; }; - // «Выбрать все» работает по видимым (отфильтрованным) слотам. - const allSelected = + // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты. + const allVisibleSelected = filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); - const toggleAll = () => { - setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey))); + const toggleAllVisible = () => { + setSelectedKeys((current) => { + const next = new Set(current); + if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot)); + else for (const slot of filteredSlots) next.add(slotKey(slot)); + return next; + }); + anchorIndexRef.current = null; }; + // Выбрать все слоты расчёта (по всем суткам), снять весь выбор. + const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey))); + const clearSelection = () => { + setSelectedKeys(new Set()); + anchorIndexRef.current = null; + }; + + // Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата). + const visibleSelectedCount = useMemo( + () => filteredSlots.reduce((n, slot) => (selectedKeys.has(slotKey(slot)) ? n + 1 : n), 0), + [filteredSlots, selectedKeys] + ); + const otherDaysSelectedCount = selectedKeys.size - visibleSelectedCount; const selectRow = (slot: SlotDTO) => { const id = `slot:${slotKey(slot)}`; @@ -416,7 +549,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { } for (const slot of filteredSlots) { const key = slotKey(slot); - for (const ring of parseWktPolygon(slot.contour)) { + for (const ring of ringsFor(key, slot.contour)) { if (ring.length < 3) continue; polygons.push({ id: `slot:${key}`, @@ -435,7 +568,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { for (const slot of bookedSlots) { const key = slotKey(slot); if (slotKeysShown.has(key)) continue; - for (const ring of parseWktPolygon(slot.contour)) { + for (const ring of ringsFor(key, slot.contour)) { if (ring.length < 3) continue; polygons.push({ id: `booked:${key}`, @@ -450,7 +583,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { } } return { polygons, lines: [], markers: [] }; - }, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys]); + }, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys, ringsFor]); const layers = useMemo( () => ({ @@ -535,10 +668,13 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setActiveHitId(undefined); }, []); - const canCalculate = hasRequest && !rangeInvalid && !calculating; + const busy = calculating || autoFilling; + const canCalculate = hasRequest && !rangeInvalid && !busy; const overlayMessage = calculating ? "Расчёт слотов…" - : !hasRequest + : autoFilling + ? "Автоподбор покрытия…" + : !hasRequest ? requestsLoading ? "Загрузка заявок…" : undefined @@ -546,6 +682,14 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»." : undefined; + // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка). + const virtualRows = rowVirtualizer.getVirtualItems(); + const virtualTotal = rowVirtualizer.getTotalSize(); + const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0; + const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0; + // Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем). + const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD; + return (
@@ -590,13 +734,32 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { + + + +
- ) : ( - - - - - - - - - - - - - - - {filteredSlots.map((slot) => { - const key = slotKey(slot); - const id = `slot:${key}`; - const booked = bookedKeys.has(key); - const isActive = activeSlotId === id; - return ( - selectRow(slot)} - > - + {padTop > 0 && ( + + + )} + {virtualRows.map((vrow) => { + const slot = filteredSlots[vrow.index]; + const key = slotKey(slot); + const id = `slot:${key}`; + const booked = bookedKeys.has(key); + const isActive = activeSlotId === id; + return ( + selectRow(slot)} + > + + + + + + + + + + ); + })} + {padBottom > 0 && ( + + + )} + +
- - КАВитокСлот / циклНачалоКонецКренСтатус
event.stopPropagation()}> +
+ {filteredSlots.length === 0 ? ( +
+ {slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."} +
+ ) : ( + + + + - - - - - - + + + + + + + + - ); - })} - -
toggleSlot(key)} - aria-label="Выбрать слот" + checked={allVisibleSelected} + onChange={toggleAllVisible} + aria-label="Выбрать видимые сутки" /> - - {slot.satelliteId} - {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} - - {slot.slotNumber} / {slot.cycle} - {formatLocalDateTime(parseLocal(slot.tn))}{formatLocalDateTime(parseLocal(slot.tk))}{slot.roll.toFixed(1)}° - {booked ? ( - бронь - ) : ( - {slotStateLabel(slot.state)} - )} - КАВитокСлот / циклНачалоКонецКренСтатус
- ) + +
event.stopPropagation()}> + {}} + onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)} + aria-label="Выбрать слот" + /> + {slot.satelliteId} + {slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"} + + {slot.slotNumber} / {slot.cycle} + {formatLocalDateTime(parseLocal(slot.tn))}{formatLocalDateTime(parseLocal(slot.tk))}{slot.roll.toFixed(1)}° + {booked ? ( + бронь + ) : ( + {slotStateLabel(slot.state)} + )} +
+ )} +
+ ) : (
@@ -741,12 +956,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
- {overlayMessage &&
{overlayMessage}
} + {overlayMessage && ( +
+ {overlayMessage} + {busy && ( + + )} +
+ )} + {pendingBook && ( +
+ Забронировать {pendingBook.length} слотов? Это крупная бронь. +
+ + +
+
+ )} {hasRequest && ( )} + {hasRequest && mapOverloaded && ( +
+ Много слотов в окне ({filteredSlots.length}) — сузьте сутки для удобной карты. +
+ )} {hasRequest && days.length > 0 && ( `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]]); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts new file mode 100644 index 0000000..8345d71 --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/slotSelection.ts @@ -0,0 +1,52 @@ +// Чистая логика выбора слотов и разбиения на чанки — выделена из TguSlotsTab, +// чтобы покрыть юнит-тестами без рендера компонента. + +/** + * Новое множество выбора после клика по строке. + * Обычный клик — переключает один ключ. Shift+клик при заданном якоре — выбирает (или + * снимает) непрерывный диапазон от якоря до текущего индекса; направление задаётся новым + * состоянием текущей строки (если она была не выбрана — диапазон выбираем, иначе снимаем). + * + * @param current Текущее множество выбранных ключей. + * @param keyAt Доступ к ключу слота по индексу в видимом (отфильтрованном) списке. + * @param index Индекс строки, по которой кликнули. + * @param shiftKey Был ли зажат Shift. + * @param anchorIndex Индекс предыдущего клика (якорь) либо null. + */ +export function selectionAfterCheck( + current: ReadonlySet, + keyAt: (index: number) => string, + index: number, + shiftKey: boolean, + anchorIndex: number | null, +): Set { + 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(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 +} diff --git a/services/pcp-tgu-ui-service/src/model/slotTypes.ts b/services/pcp-tgu-ui-service/src/model/slotTypes.ts index ebecf33..66cafb1 100644 --- a/services/pcp-tgu-ui-service/src/model/slotTypes.ts +++ b/services/pcp-tgu-ui-service/src/model/slotTypes.ts @@ -4,6 +4,12 @@ /** Признак ветви витка (восходящая/нисходящая). */ export type RevolutionSign = "ASC" | "DESC"; +/** + * Стратегия автоформирования покрытия (slots-service: SlotCoverageStrategy). + * GREEDY — жадный подбор; CONTINUOUS — непрерывное покрытие. Не задано — все слоты-кандидаты. + */ +export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS"; + /** Статус слота из расчёта покрытия. */ export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string; @@ -52,4 +58,6 @@ export type RequestSlotsParams = { satellites?: number[]; /** Угол Солнца, град (для оптики); не задано — без ограничения. */ sun?: number; + /** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */ + coverageStrategy?: SlotCoverageStrategy; }; diff --git a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css index 2b85c5e..10f26c9 100644 --- a/services/pcp-tgu-ui-service/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ui-service/src/styles/tgu-planning.css @@ -925,11 +925,44 @@ .tgu-slots-list { min-width: 0; min-height: 0; - overflow: auto; + 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); @@ -1363,6 +1396,53 @@ 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 { display: grid; gap: 0.4rem; From 8c18959c6b468aaf14d7b22a9d74268f69f36dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 15 Jun 2026 10:06:03 +0300 Subject: [PATCH 11/11] =?UTF-8?q?build:=20=D0=BE=D1=82=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B4=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=BF=D0=BD=D1=8B=D0=B9=20maven-proxy=20(repo.nstart.clo?= =?UTF-8?q?ud=20=D0=BE=D1=82=D0=B4=D0=B0=D1=91=D1=82=20HTML),=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD=D1=8B=20=D1=81=20=D0=BF=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20=D1=80=D0=B5=D0=BF?= =?UTF-8?q?=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=B5=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle.kts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8033846..7c18e3b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,9 +28,9 @@ include(":services:pcp-gateway-service") pluginManagement { repositories { - maven { - url = uri("https://repo.nstart.cloud/repository/maven-proxy/") - } + // repo.nstart.cloud/maven-proxy сейчас отдаёт HTML вместо артефактов — + // плагины резолвим напрямую с публичных репозиториев. + // maven { url = uri("https://repo.nstart.cloud/repository/maven-proxy/") } mavenCentral() gradlePluginPortal() }