Перейти к содержанию

Фронтенд на https://aerozveno.ff — раскатка и связка с бэкендом

Дата: 2026-09-16. Ветка feat/frontend-deploy. Кластер — n1 (kubectl --context n1), ns aerozveno.

Задача была из четырёх частей: собрать четыре модуля под один домен, выложить бандл в S3, поднять nginx со статикой и — главное — перевести интерфейс на реальный API.


1. Что развёрнуто и по каким адресам

Что Адрес Объект в кластере
Интерфейс (шелл + три ремоута) https://aerozveno.ff/ deploy/geoscan-frontend (nginx)
REST/SSE бэкенда https://aerozveno.ff/api/* deploy/geoscan (gateway, NestJS)
Ops-ручки /healthz, /readyz, /status, /metrics, /api/docs тот же gateway
Объектное хранилище бандла minio.aerozveno.svc.cluster.local:9000 (ClusterIP) sts/geoscan-minio
Метрики статики geoscan-frontend.aerozveno.svc:9113 сайдкар nginx-exporter

Всё tailnet-only, TLS от внутреннего CA (ff-ca/ff-ca.crt), ingress с whitelist-source-range: 100.64.0.0/10,10.42.0.0/16.

Образ гейтвея не пересобирался — в этом и был смысл вынесения статики наружу. Заглушка src/backend/gateway/public/index.html осталась в образе, но больше не видна: / уходит в nginx.

Манифесты — k3s/aerozveno/, разворачиваются одним kubectl --context n1 apply -k k3s/aerozveno/ --server-side. Каталог в репозитории заведён в этой задаче: раньше он существовал только на n1 (/root/deploy-apps/aerozveno/) и разошёлся с k3s/geoscan/ (тот остался нацелен на ns geoscan и домен geoscan.ff). Живое состояние втянуто в репозиторий, к нему добавлены 35-minio.yaml, 45-deployment-frontend.yaml и переписанный 60-ingress.yaml.

Маршрутизация

k3s/aerozveno/60-ingress.yaml: префиксы /api, /healthz, /readyz, /status, /metrics → сервис geoscan (gateway); / → сервис geoscan-frontend (nginx). ingress-nginx сопоставляет более длинный префикс первым, поэтому порядок в списке роли не играет.

Заодно на ingress добавлены proxy-buffering: "off" и proxy-read-timeout: 600 — без этого SSE /api/plans/{id}/events доезжает до браузера одним куском в конце, и полоса прогресса расчёта в топбаре не двигается.


2. Раскладка бандла и настройка ремоутов

/                        index.html + assets шелла + remoteEntry.js + config.json
/remotes/planner/        remoteEntry.js + assets
/remotes/validator/      remoteEntry.js + assets
/remotes/viewer3d/       remoteEntry.js + assets

base в vite-конфигах. Каждому ремоуту задан свой префикс, шеллу — корень:

Модуль base
apps/shell /
apps/planner /remotes/planner/
apps/validator /remotes/validator/
apps/viewer3d /remotes/viewer3d/

Конфиги переведены в функциональную форму defineConfig(({ command }) => ({ … })), и base подставляется только при command === 'build'. В dev-сервере остаётся /, иначе ремоут на localhost:5174 перестал бы открываться по корню и npm run dev сломался бы.

Адреса ремоутов. В apps/shell/vite.config.ts они уже были вынесены в process.env.REMOTE_PLANNER / REMOTE_VALIDATOR / REMOTE_VIEWER3D, и значения по умолчанию на момент задачи были уже относительными (/remotes/<name>/remoteEntry.js), а не http://localhost:517x/..., как сказано в постановке. Проверено: @module-federation/vite относительный путь принимает — в собранном шелле лежит ровно /remotes/planner/remoteEntry.js, абсолютный адрес на https://aerozveno.ff не понадобился.

Сам remoteEntry.js ремоута подтягивает свои чанки относительным ./assets/… через new URL(path, import.meta.url), а index.html и CSS ремоута — абсолютным /remotes/<name>/assets/… из base. Обе формы разрешаются правильно.

Сборка одной командой:

cd src/frontend
npm run build:site       # = npm run build && node tools/assemble-site.mjs

tools/assemble-site.mjs раскладывает apps/*/dist в src/frontend/dist-site/ по схеме выше и выбрасывает index.html ремоутов (они нужны только в standalone-режиме и в раскладке только путают). dist-site/ добавлен в .gitignore.


3. S3: что получилось и почему не Yandex Object Storage

Yandex Object Storage оказался недоступен — это ограничение, а не выбор

Ключи yc/YC_CR_SA_STATIC_KEY_ID + yc/YC_CR_SA_STATIC_SECRET принадлежат сервис-аккаунту реестра образов (ajeane4reqpr65vjbkfn, папка b1g31q6gtmkd96mf7eb5). Прав на Object Storage у них нет. Дословно:

LIST FAIL: {'Code': 'AccessDenied', 'Message': 'Access Denied', 'Resource': '/'}
create aerozveno-frontend: FAIL {'Code': 'AccessDenied', 'Message': 'Access Denied', 'Resource': '/aerozveno-frontend'}

Постановка предлагала в этом случае «завести отдельный SA и ключи». Это тоже упирается в права: IAM-токен по authorized_key.json того же SA выдаётся, но дальше всё закрыто —

clouds:                 {}                       # ListClouds пусто: нет resource-manager
buckets(storage api):   HTTP 403 "Access Denied"
create bucket:          HTTP 403 "Access Denied"
list SAs:               {}                       # нет iam.serviceAccounts.list

Учётки с правами заводить сервис-аккаунты или назначать роли в папке в creds-store нет (namespace'ы: gitea, monitoring, project/agent-board, project/geoscan, s3, yc, yc/cr-sa).

Что сделано вместо

Поднят собственный приватный S3 — MinIO в том же ns aerozveno (k3s/aerozveno/35-minio.yaml). Архитектура из постановки сохранена один в один: бандл живёт как артефакт в объектном хранилище, nginx получает его оттуда. Менялся только провайдер S3.

Параметр Значение
Бакет aerozveno-frontend
Эндпоинт http://minio.aerozveno.svc.cluster.local:9000
Доступ ClusterIP, наружу не публикуется; MINIO_BROWSER=off
Политика приватный, анонимная политика не выставлена
Диск PVC 2 GiB, local-path

Записано в creds-store (namespace project/geoscan): S3_FRONTEND_BUCKET, S3_FRONTEND_ENDPOINT, S3_FRONTEND_ACCESS_KEY, S3_FRONTEND_SECRET_KEY. В кластере — secret geoscan-s3.

Приватность проверена изнутри кластера:

anon GET /aerozveno-frontend/index.html -> HTTP 403

Выгрузка

aws/mc/yc на станции нет, а ClusterIP n1 со станции не маршрутизируется (в отличие от n2, который анонсирует subnet-route). Поэтому scripts/publish-frontend.py на boto3 сам поднимает kubectl port-forward svc/minio на время выгрузки и гасит его в конце:

uv venv /tmp/s3venv && uv pip install --python /tmp/s3venv/bin/python boto3
/tmp/s3venv/bin/python scripts/publish-frontend.py          # выгрузить dist-site
/tmp/s3venv/bin/python scripts/publish-frontend.py --list    # что лежит в бакете

Ключи скрипт берёт из creds-store по HTTPS с внутренним CA; переменные S3_ACCESS_KEY / S3_SECRET_KEY — запасной путь. Объекты, которых больше нет в dist-site, из бакета удаляются.

Как nginx получает файлы

k3s/aerozveno/45-deployment-frontend.yaml, три контейнера + init:

  1. init fetch-bundle (quay.io/minio/mc) — mc mirror --overwrite --remove бакета в emptyDir, затем проверка test -f /srv/www/index.html. Пока бандла нет, под не стартует, то есть пустой корень наружу не попадает.
  2. nginx (nginx:1.27-alpine) — отдаёт /srv/www только на чтение.
  3. resync-bundle — раз в минуту повторяет mc mirror --overwrite (сверх постановки): новая выгрузка в бакет подхватывается без пересоздания пода. Без --remove: имена чанков содержат хеш содержимого, поэтому дописывание новых файлов поверх старых безопасно, а удаление на лету может увести файл из-под уже открытой вкладки. Гарантированно чистая раскатка — kubectl -n aerozveno rollout restart deploy/geoscan-frontend.
  4. nginx-exporterstub_status → Prometheus.

SPA-fallback (шелл на BrowserRouter): location / { try_files $uri $uri/ /index.html; }. Перехватить API он не может: location /api/ { return 404; } стоит отдельным правилом выше — если запрос к API почему-то дойдёт до nginx, он получит честный 404, а не HTML вместо JSON. assets/ кешируются на год как immutable, remoteEntry.js и config.jsonno-store.


4. Связка фронтенда с бэкендом

Что было на самом деле

Постановка говорит «в src/frontend нет ни одного обращения к API». На момент начала работы это уже не так: в дереве есть пакеты packages/api-client (клиент, эндпоинты fleet/scenarios/ plans/validate) и packages/state (маппинг API↔домен, опрос плана, SSE, бутстрап каталога). Экраны на них уже опирались.

Чего не было — ни одной проверки против живого бэкенда. Первый же браузерный прогон показал, что связка местами не работает. Ниже — что именно было найдено и исправлено.

Таблица расхождений форматов фронт ↔ API

# Где В чём расхождение Что сделал
1 POST /api/scenarios, ВПП Фронт слал turnaround_time_s (секунды). scenario.schema.json требует turnaround_time_min и имеет additionalProperties: false422 SCHEMA_VALIDATION_FAILED mappers.ts: шлём turnaround_time_min в минутах. Обратное чтение принимает оба имени
2 POST /api/scenarios, критерий Фронт слал objective.energy_reserve_frac; схема требует energy_reserve → 422 Шлём energy_reserve; при чтении принимаем оба
3 POST /api/scenarios, ЛЗП Фронт генерировал по синтетической площадке <vpp>-lzp на каждую ВПП. У сценариев сервера landing_sites: [] — выдуманная площадка меняет условие задачи Отдаём только площадки с kind === 'reserve'; для сценариев с сервера — пустой список
4 POST /api/validate Фронт слал planToApi(plan) — обратный маппинг доменного плана, в котором missions оставались camelCase → 400, отчёт молча падал в браузерный Храним исходные документы сервера (scenarioDoc, planDoc) и валидируем ровно их. Ответ 200
5 Ответ POST /api/validate, структура Сервер отдаёт плоский checks[] с полем group; домен ждёт groups[].checks[]. Из-за этого отчёт показывал «Проверок выполнено 0 / 0» Группируем по group, названия групп по-русски в CHECK_GROUP_TITLES. Теперь 24 / 24
6 Ответ /api/validate, поля проверки Сервер: value/limit/units числами + message. Домен: строковые value, margin, detail Форматируем: «153.19 m», «предел 150 m»; messagedetail
7 Ответ /api/validate, индекс безопасности Полей safety_index, crc32, core, generated_at в ответе нет safetyIndex считаем как долю пройденных проверок (87.5 при 21/24) и помечаем: core = «валидатор 0.1.0» из validator_version, crc32 = «—». Числа из solve_time_ms берём из validated_in_s
8 plan.missions[], сводка на борт Сервер даёт только total_flight_time_s. Полей total_distance_m, transect_count, photos на борт нет — панель «Борта в плане» показывала 00:00:00, 0,0 км, 0 / 1, 0 при непустом плане Досчитываем по фазам: длительность из total_flight_time_s, дистанция — сумма длин геометрий фаз по дуге большого круга, галсы — число различных transect_id в survey-фазах, снимки — сумма photos. Сошлось: 16,1 км при metrics.total_distance_m = 16096
9 plan.missions[].sorties[].start_s Поле в ответе есть, маппер жёстко ставил startS: 0 → диаграмма Ганта для нескольких вылетов ложилась в одну точку Читаем start_s
10 plan.geometry[].transects Сервер отдаёт [{id, task_id, line: {LineString}, length_m}], маппер выбрасывал их (transects: []) → «t1 · 1.0 км² · 0 галсов» Маппим; line.coordinates[LngLat, LngLat], строковый id ("1") → числовой. Стало «15 галсов»
11 metrics.violations Сервер считает 7 видов, домен знал 5 (altitude и turnaround терялись, в том числе из суммы «Нарушений») Расширен тип PlanMetrics['violations'] до 7 ключей; офлайн-счётчик отдаёт их нулями (он эти два вида не проверяет)
12 Сценарий: перекрытия В документах сервера у задания нет overlap_forward_frac / overlap_side_frac Оставлены значения по умолчанию 70 % / 60 % при чтении. Расхождение не устранено — см. «Что осталось на моках»
13 Сценарий: борт В документах сервера у борта нет enabled и battery_left_frac, есть available_from, которого нет в домене Читаем enabled как «включён по умолчанию», заряд 100 %. available_from фронт игнорирует. Расхождение не устранено
14 Сценарий: ВП В документах сервера у airspace нет floor_m / ceiling_m Умолчания 0 / 500 м. Расхождение не устранено
15 Список сценариев GET /api/scenarios отдаёт только scenario_id, name, created_at, source — без числа заданий, площади и критерия Таблица на старте показывает и 0,0 км² в этих колонках с подписью «с сервера»
16 Гейтвей не валидирует objective POST /api/plans с objective: "min_makespan" принимается с 202, планировщик отбивается позже (известный дефект постановки) Фронт шлёт только makespan / total_flight_time / pareto — из типа Criterion. Дефект гейтвея не чинил, задача этого не требовала

Найденный и исправленный дефект: правка сценария затирала демо

Не про формат, но нашлось по дороге и важнее половины таблицы.

POST /api/scenarios на гейтвее — upsert по scenario_id (ON CONFLICT (id) DO UPDATE), а GET /api/scenarios/{id} отдаёт пользовательскую запись раньше встроенной. Фронт при сохранении правленого сценария слал исходный scenario_id — то есть отредактированный s01-simple затирал эталонный демо-сценарий для всех, навсегда.

Я наступил на это сам при отладке (перезаписал s01-simple) и восстановил документ из встроенного файла образа гейтвея; проверено, что expected, start_time и spare_batteries на месте. Исправление в коде: правленый сценарий всегда уезжает под собственным идентификатором <base>-draft-<8 hex> (draftScenarioId в api-compute.ts).

Смежная проблема: ensureScenarioOnServer переиспользовал serverScenarioId, не глядя на то, правился ли сценарий после загрузки. Правки парка, GSD, ветра и геометрии молча терялись — сервер считал по старому документу. Добавлена подписка в create-mission-store.ts: если scenario изменился, а scenarioDoc в том же обновлении — нет (то есть это локальная правка, а не загрузка с API), привязка к серверной копии сбрасывается и сценарий пересохраняется.

Проверено в браузере: после изменения GSD на 5 см прогон даёт 201 POST /api/scenarios → новый расчёт. Черновики, созданные прогонами, из БД удалены — в /api/scenarios снова ровно 10 сценариев.

Что теперь берётся с сервера

Данные Ручка
Каталог БВС и нагрузок GET /api/fleet, GET /api/payloads (вместо packages/domain/src/fleet.ts)
Список сценариев GET /api/scenarios
Документ сценария GET /api/scenarios/{id}
Сохранение правленого сценария POST /api/scenarios
Расчёт POST /api/plans + SSE /events, с откатом на опрос GET /api/plans/{id}
Пересчёт при отказе борта POST /api/plans/{id}/replan
Валидация POST /api/validate с телом {scenario, plan}
Выгрузка KML / GeoJSON / QGC .plan / CSV GET /api/plans/{id}/export/{fmt}

Что осталось на моках и почему

Что Почему Как помечено
Подсказки при рисовании полигона, предварительные высота и шаг галсов в панели задания У API эквивалента нет — это интерактив, счёт на каждое движение мыши. Постановка прямо просит оставить Не помечается: это расчёт черновика, а не результат
3D-сцена (viewer3d) Синтетический DEM; рельефа и тайлов у бэкенда нет
Мультикритериальный анализ, экран «Парето» Ручки для фронта Парето нет; pareto после расчёта по API остаётся пустым Экран пустой, ложных чисел не показывает
Отчёт валидатора, если POST /api/validate недоступен Откат на браузерный счётчик @geoscan/domain Помечено в интерфейсе: тег рядом с заголовком — «Источник: POST /api/validate» (синий) либо «Источник: браузерный расчёт (сервер недоступен)» (оранжевый)
Переключатель «Офлайн» в топбаре Осознанный демо-режим целиком на @geoscan/domain Тем же тегом: «Источник: браузерный расчёт (офлайн-режим)»
Перекрытия, заряд АКБ, floor/ceiling ВП (строки 12–14 таблицы) Полей нет в документах сервера; подставлять «разумные» числа на стороне фронта — значит расходиться с сервером молча Не помечено в интерфейсе — честный долг, см. «Что не доделано»
Подложка карты OFFLINE_STYLE — один background-слой, внешних тайлов нет по проекту (контур tailnet-only). Геометрия рисуется через deck.gl Не дефект раскатки

5. Мониторинг

Цель добавлена в существующий job aerozveno (правится в fairflow-crm/k3s/monitoring/prometheus.yml, зеркало — k3s/monitoring/prometheus-aerozveno-scrape.yaml):

geoscan-frontend.aerozveno.svc.cluster.local:9113   component: frontend

Состояние целей после раскатки:

  gateway    geoscan.aerozveno.svc.cluster.local:80                  up
  planner    planner.aerozveno.svc.cluster.local:3001                up
  frontend   geoscan-frontend.aerozveno.svc.cluster.local:9113       up

Алерт AerozvenoFrontendDown (up{component="frontend"} == 0 3 минуты) — в k3s/monitoring/aerozveno.rules.yml.

Дашборд https://grafana.ff/d/aerozveno, четыре новые панели:

  • «Статика фронтенда жива» — up по цели;
  • «Статика: запросов в минуту» — nginx_http_requests_total;
  • «Статика: соединения nginx» — active/reading/writing/waiting;
  • «Браузер → API: запросы шелла к гейтвею» — geoscan_http_requests_total по маршрутам /api/(scenarios|plans|validate): панель отвечает ровно на вопрос «связка живая или только поды Running».

6. Проверки — дословный вывод

1. / отдаёт index шелла, а не заглушку гейтвея

<!doctype html>
<html lang="ru">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Геоскан · Планировщик беспилотных авиационных работ</title>
    <script type="module" crossorigin src="/assets/mf-entry-bootstrap-0-00e057ea.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/style-CCGB7cq9.css">

(заглушка гейтвея была <p>geoscan gateway</p>, 162 байта)

2. remoteEntry.js трёх ремоутов

/remotes/planner/remoteEntry.js -> HTTP 200, Content-Type: application/javascript, 980 байт
/remotes/validator/remoteEntry.js -> HTTP 200, Content-Type: application/javascript, 988 байт
/remotes/viewer3d/remoteEntry.js -> HTTP 200, Content-Type: application/javascript, 984 байт

3. Прямые ссылки (SPA-fallback, F5)

/plan -> HTTP 200, text/html
/scenario -> HTTP 200, text/html
/validator -> HTTP 200, text/html
/export -> HTTP 200, text/html
/pareto -> HTTP 200, text/html
/viewer3d -> HTTP 200, text/html
/airspace -> HTTP 200, text/html

4. Маршрутизация API не сломана

/api/scenarios -> HTTP 200, application/json; charset=utf-8
/api/fleet -> HTTP 200, application/json; charset=utf-8
/api/payloads -> HTTP 200, application/json; charset=utf-8
/api/docs -> HTTP 200, text/html
/healthz -> HTTP 200, application/json; charset=utf-8
/readyz -> HTTP 200, application/json; charset=utf-8
/status -> HTTP 200, application/json; charset=utf-8
/metrics -> HTTP 200, text/plain; version=0.0.4; charset=utf-8

# SPA-fallback не перехватывает несуществующий путь API:
/api/nope -> HTTP 404, application/json; charset=utf-8

5. Браузерный путь целиком, headless

Playwright + Chromium, против живого https://aerozveno.ff. Скрипт прогона — src/frontend/tools/e2e-aerozveno.mjs (запуск — см. раздел 8), скриншоты — /tmp/e2e/final/.

--- ШАГ: 1. Открыть шелл ---
  [api] 200 GET /api/fleet
  [api] 200 GET /api/payloads
  [api] 200 GET /api/scenarios
  title: Геоскан · Планировщик беспилотных авиационных работ
  строк в таблице сценариев: 10
  источник списка: 10 сценариев · GET /api/scenarios
--- OK: 1. Открыть шелл ---

--- ШАГ: 2. Открыть сценарий «Один борт, прямоугольник 1×1 км» (s01-simple) ---
  [api] 200 GET /api/scenarios/s01-simple
  парк БВС на экране: борт gemini-01 виден (каталог с GET /api/fleet)
--- OK: 2. Открыть сценарий «Один борт, прямоугольник 1×1 км» (s01-simple) ---

--- ШАГ: 3. «Рассчитать» → дождаться плана ---
  [api] 200 GET /api/scenarios/s01-simple
  [api] 202 POST /api/plans
  [api] 200 GET /api/plans/2aac8067-4176-4b2c-8912-40f51ef4ef10/events
  [api] 200 GET /api/plans/2aac8067-4176-4b2c-8912-40f51ef4ef10
  [api] 200 GET /api/plans/2aac8067-4176-4b2c-8912-40f51ef4ef10
  [api] 200 POST /api/validate
  статус: ПЛАН РАССЧИТАН за ~2 с
  --- экран «План» ---
МЕТРИКИ ПЛАНА · Время работ · 00:28:20 · Суммарный налёт · 00:28:20 · Покрытие · 100,0 % ·
Нарушений · 0 · … · Галсов: · 15 · Разворотов: · 14 · Кадров: · 384 · Решатель: · feasible ·
0.71 с · План полётов · НАРУШЕНИЙ НЕТ · СВОДКА · Время работ (makespan) · 00:28:20 ·
Суммарный налёт · 00:28:20 · Суммарная дистанция · 16,1 км · Вылетов · 1 · Галсов · 15 ·
Разворотов · 14 · Доля времени на развороты · 5,9 % · Снимков · 384 ·
РАСЧЁТНАЯ ГЕОМЕТРИЯ ЗАДАНИЙ · t1 · 1.0 км² · 15 галсов · Высота · 153 м · Шаг галсов · 72 м ·
Захват кадра · 180 × 119 м · Угол галсов · 3° · Интервал съёмки · 3.6 с ·
БОРТА В ПЛАНЕ · 1 борта · Геоскан Gemini · gemini-01 · Задание · Налёт · 00:28:20 ·
Дистанция · 16,1 км · Галсов / вылетов · 15 / 1 · Снимков · 384 ·
ИНДЕКС БЕЗОПАСНОСТИ · 87.5 · Открыть отчёт валидатора
--- OK: 3. «Рассчитать» → дождаться плана ---

--- ШАГ: 4. Отчёт валидатора (переход внутри SPA) ---
  метка источника отчёта: Источник: POST /api/validate
  --- отчёт ---
Отчёт валидатора и аудит безопасности полётов · ЕСТЬ БЛОКИРУЮЩИЕ НАРУШЕНИЯ ·
Источник: POST /api/validate · CRC32: — · ядро валидатор 0.1.0 · 21 мс ·
ПРОВЕРОК ВЫПОЛНЕНО · 24 / 24 · КРИТИЧЕСКИЕ ОШИБКИ · 1 ошибок · ПРЕДУПРЕЖДЕНИЯ · 2 требуют внимания ·
ИНДЕКС БЕЗОПАСНОСТИ · 87.5 / 100 · Все проверки 24 · Замечания 3 · Успешно 21 ·
Воздушное пространство и бесполётные зоны · … · Порог 150 м (разрешение на ИВП) ВНИМАНИЕ ·
максимальная высота 153 м выше 150 м — потребуется разрешение на ИВП (ALT_ABOVE_150M) ·
153.19 m · предел 150 m · …
--- OK: 4. Отчёт валидатора (переход внутри SPA) ---

--- ШАГ: 5. Экспорт KML через GET /api/plans/{id}/export/kml ---
  [api] 200 GET /api/plans/2aac8067-4176-4b2c-8912-40f51ef4ef10/export/kml
  [api] 200 GET /api/plans/2aac8067-4176-4b2c-8912-40f51ef4ef10/export/geojson
  файл: s01-simple.kml (10496 байт)
  первые 500 символов:
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>gemini-01</name>
      <Folder>
        <name>Вылет 1</name>
        <Placemark>
          <name>takeoff 0</name>
          <LineString>
            <tessellate>1</tessellate>
            <altitudeMode>relativeToGround</altitudeMode>
            <coordinates>37.6200000,55.7600000,0.0 37.6200000,55.7600000,0.0</coordinates>
          </LineString>
        </Placemark>
--- OK: 5. Экспорт KML через GET /api/plans/{id}/export/kml ---

--- ШАГ: 6. Правка сценария должна уходить на сервер (POST /api/scenarios) ---
  GSD изменён на 5 см
  [api] 201 POST /api/scenarios
  [api] 202 POST /api/plans
  [api] 200 GET /api/plans/941fc941-b02e-430a-bf87-403ce8a7dcbf/events
  [api] 200 GET /api/plans/941fc941-b02e-430a-bf87-403ce8a7dcbf
  [api] 200 POST /api/validate
  статус после правки: ПЛАН РАССЧИТАН за ~4 с
  POST /api/scenarios был: да
--- OK: 6. Правка сценария должна уходить на сервер (POST /api/scenarios) ---

--- ШАГ: 7. Прямая ссылка /plan (F5) ---
  [api] 200 GET /api/fleet
  [api] 200 GET /api/payloads
  url: https://aerozveno.ff/plan | шелл отрисован: true
  содержимое: Сценарий не загружен
--- OK: 7. Прямая ссылка /plan (F5) ---

=== ОШИБКИ КОНСОЛИ ===
  нет

Ни одного ответа 4xx/5xx, ни одной ошибки в консоли за весь прогон.

6. Метрики гейтвея растут от кликов в браузере

Срез geoscan_http_requests_total до и после того же прогона:

маршрут                                              метод   код      до  после  дельта
----------------------------------------------------------------------------------------
/api/fleet                                           GET     200      13     14      +1
/api/payloads                                        GET     200      11     12      +1
/api/plans/{id}                                      GET     200      26     29      +3
/api/plans/{id}/events                               GET     200       9     11      +2
/api/plans/{id}/export/geojson                       GET     200       4      5      +1
/api/plans/{id}/export/kml                           GET     200       4      5      +1
/api/scenarios                                       GET     200      20     21      +1
/api/scenarios/s01-simple                            GET     200      19     21      +2
/api/plans                                           POST    202      13     15      +2
/api/scenarios                                       POST    201       3      4      +1
/api/validate                                        POST    200      11     13      +2

Дельты сходятся с сетевым логом прогона один в один.

Дополнительно: в кластере лежит ровно то, что собрано из репозитория

index.html в кластере: 00e24f54aa76806846f9318453e72f6b
index.html в дереве:   00e24f54aa76806846f9318453e72f6b
СОВПАДАЕТ — развёрнуто ровно то, что собрано из репозитория

7. Что не доделано

  1. Yandex Object Storage не задействован. Прав у имеющихся ключей нет, завести SA нечем. Бандл лежит в MinIO внутри кластера. Чтобы переехать в YC, нужен сервис-аккаунт с ролью storage.editor в папке b1g31q6gtmkd96mf7eb5 и его статические ключи в creds-store; код выгрузки менять не придётся — только эндпоинт и ключи.
  2. Расхождения 12–14 таблицы не устранены и не помечены в интерфейсе. Перекрытия (70/60 %), заряд АКБ (100 %) и floor/ceiling разрешённого ВП (0/500 м) подставляются фронтом по умолчанию, потому что в документах сценария с сервера этих полей нет. Пользователь видит числа и не знает, что они не с сервера. Правильное решение — добавить поля в scenario.schema.json и в демо-сценарии, а не подкрашивать интерфейс. Отдельная задача.
  3. Состояние живёт только в памяти вкладки. F5 на /plan отдаёт 200 и шелл, но план теряется («Сценарий не загружен»). activePlanId есть — достаточно класть его в URL или sessionStorage и восстанавливать через GET /api/plans/{id}. Не делал: выходит за рамки задачи.
  4. Экран «Парето» пуст при работе по API. Ручки нет, pareto: []. Ложных чисел не показывает, но и пользы не приносит.
  5. Гейтвей не валидирует objective. min_makespan принимается с 202 и падает в планировщике. Фронт такого значения не шлёт, но дефект гейтвея остался — чинить его задача не просила.
  6. validator не отдаёт ops-http (известный дефект A-02) — цели в Prometheus для него по-прежнему нет.
  7. Стухшие .d.ts в packages/domain/src. Рядом с store.ts лежит store.d.ts от прошлых сборок, и он уже разошёлся с исходником (в нём нет dataSource, catalogLoaded, добавленных мной полей). Сейчас TypeScript берёт .ts и всё собирается, но это мина. Удалять не стал — не мой объём.
  8. Пересинхронизация из бакета не атомарна. Сайдкар подкладывает новые файлы в работающий emptyDir. Для контент-хешированных чанков это безопасно, но теоретически между обновлением index.html и появлением всех чанков есть окно. Гарантию даёт rollout restart.
  9. Прогон покрывает один сценарий (s01-simple). Остальные девять через браузер не гонялись.

8. Как повторить раскатку

# сборка и раскладка под один домен
cd src/frontend && npm ci && npm run build:site

# выгрузка в приватный бакет
cd ../.. && /tmp/s3venv/bin/python scripts/publish-frontend.py

# кластер
kubectl --context n1 apply -k k3s/aerozveno/ --server-side
kubectl --context n1 -n aerozveno rollout restart deploy/geoscan-frontend
kubectl --context n1 -n aerozveno rollout status deploy/geoscan-frontend

# проверка
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/

# сквозной браузерный прогон. Playwright в зависимостях монорепы нет, и запускать
# скрипт надо оттуда, где он установлен: резолвинг пакетов идёт от каталога файла.
mkdir -p /tmp/e2e && cd /tmp/e2e && npm i playwright
cp <репозиторий>/src/frontend/tools/e2e-aerozveno.mjs .
node e2e-aerozveno.mjs https://aerozveno.ff /tmp/e2e/out