diff --git a/config-repo/pcp-ui-service.yaml b/config-repo/pcp-ui-service.yaml index 99ea857..2d544d3 100644 --- a/config-repo/pcp-ui-service.yaml +++ b/config-repo/pcp-ui-service.yaml @@ -37,6 +37,7 @@ settings: earth-grid-service: ${pcp.services.request} complex-mission-service: ${pcp.services.complex-mission-service} dynamic-plan-service: ${pcp.services.dynamic-plan} + tgu-service: ${pcp.services.tgu} satellite-catalog-service: ${pcp.services.satellite-catalog} ballistics-service: ${pcp.services.ballistics} stations-service: ${pcp.services.stations} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt index 7025c99..f23b768 100644 --- a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/configuration/GlobalExceptionHandler.kt @@ -6,6 +6,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ControllerAdvice import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.support.WebExchangeBindException +import org.springframework.web.server.ResponseStatusException import reactor.core.publisher.Mono class CustomValidationException(message: String) : RuntimeException(message) @@ -25,6 +26,11 @@ class GlobalExceptionHandler { return ResponseEntity.internalServerError() .body(mapOf("error" to ex.message!!)) } + @ExceptionHandler(ResponseStatusException::class) + fun handleStatusError(ex: ResponseStatusException): ResponseEntity> { + return ResponseEntity.status(ex.statusCode) + .body(mapOf("error" to (ex.reason ?: ex.message))) + } @ExceptionHandler(WebExchangeBindException::class) fun handleValidationExceptions(ex: WebExchangeBindException): Mono>> { val errors = mutableMapOf() @@ -43,4 +49,4 @@ class GlobalExceptionHandler { } return ResponseEntity.badRequest().body(errors) } -} \ No newline at end of file +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt new file mode 100644 index 0000000..ee7c0fb --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/controller/TguPlanningController.kt @@ -0,0 +1,46 @@ +package space.nstart.pcp.slots_service.controller + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision +import space.nstart.pcp.slots_service.service.TguPlanningService +import java.util.UUID + +@Controller +class TguPlanningPageController { + + @GetMapping("/tgu-planning") + fun tguPlanningPage(): String = "tgu-planning" +} + +@RestController +@RequestMapping("/api/tgu-planning") +class TguPlanningRestController( + private val tguPlanningService: TguPlanningService +) { + + @GetMapping("/platforms") + fun platforms() = tguPlanningService.platforms() + + @GetMapping("/plans") + fun plans(@RequestParam(defaultValue = "7") historyDays: Long) = + tguPlanningService.plans(historyDays) + + @GetMapping("/spacecraft/{spacecraftId}/plans") + fun spacecraftPlans( + @PathVariable spacecraftId: String, + @RequestParam(defaultValue = "7") historyDays: Long + ) = tguPlanningService.spacecraftPlans(spacecraftId, historyDays) + + @PostMapping("/plans/{planId}/decision") + fun sendDecision( + @PathVariable planId: UUID, + @RequestParam decision: TguPlanDecision, + @RequestParam(required = false) reason: String? + ) = tguPlanningService.sendDecision(planId, decision, reason) +} diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt new file mode 100644 index 0000000..42fb510 --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/dto/tgu/TguPlanningDTO.kt @@ -0,0 +1,40 @@ +package space.nstart.pcp.slots_service.dto.tgu + +import java.time.Instant +import java.time.LocalDateTime +import java.util.UUID + +data class TguPlatformResponse( + val id: String?, + val businessKey: String?, + val name: String?, + val status: String?, + val noradId: Long?, + val mission: String?, + val validFrom: Instant?, + val validTo: Instant? +) + +data class TguPlanResponse( + val planId: UUID, + val spacecraftId: String, + val startTime: LocalDateTime, + val endTime: LocalDateTime, + val kppId: String, + val status: String +) + +enum class TguPlanDecision { + ACCEPTED, + REJECTED +} + +data class TguPlanDecisionMessage( + val eventId: UUID, + val spacecraftId: String, + val planId: UUID, + val attemptId: UUID, + val decision: TguPlanDecision, + val decisionTime: LocalDateTime, + val reason: String? +) diff --git a/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt new file mode 100644 index 0000000..74620eb --- /dev/null +++ b/services/pcp-ui-service/src/main/kotlin/space/nstart/pcp/slots_service/service/TguPlanningService.kt @@ -0,0 +1,154 @@ +package space.nstart.pcp.slots_service.service + +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode +import org.springframework.stereotype.Service +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientRequestException +import org.springframework.web.server.ResponseStatusException +import org.springframework.web.util.UriComponentsBuilder +import reactor.core.publisher.Mono +import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision +import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecisionMessage +import space.nstart.pcp.slots_service.dto.tgu.TguPlanResponse +import space.nstart.pcp.slots_service.dto.tgu.TguPlatformResponse +import tools.jackson.databind.ObjectMapper +import java.util.UUID + +@Service +class TguPlanningService(webClientBuilderProvider: ObjectProvider) { + private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() + + @Value("\${settings.tgu-service:pcp-tgu-service}") + private lateinit var tguServiceUrl: String + + fun platforms(): List = + webClientBuilder.build() + .get() + .uri(buildUri("/api/platforms")) + .retrieve() + .onStatus({ status -> status.isError }, ::mapReadError) + .bodyToFlux(TguPlatformResponse::class.java) + .collectList() + .block() + ?: emptyList() + + fun plans(historyDays: Long): List = + webClientBuilder.build() + .get() + .uri(buildUri("/api/plans") { builder -> + builder.queryParam("historyDays", historyDays) + }) + .retrieve() + .onStatus({ status -> status.isError }, ::mapReadError) + .bodyToFlux(TguPlanResponse::class.java) + .collectList() + .block() + ?: emptyList() + + fun spacecraftPlans(spacecraftId: String, historyDays: Long): List = + webClientBuilder.build() + .get() + .uri(buildUri("/api/plans/{spacecraftId}") { builder -> + builder.queryParam("historyDays", historyDays) + .buildAndExpand(spacecraftId) + }) + .retrieve() + .onStatus({ status -> status.isError }, ::mapReadError) + .bodyToFlux(TguPlanResponse::class.java) + .collectList() + .block() + ?: emptyList() + + fun sendDecision(planId: UUID, decision: TguPlanDecision, reason: String?): TguPlanDecisionMessage = + runCatching { + webClientBuilder.build() + .post() + .uri(buildUri("/test/tgu/plans/{planId}/decision") { builder -> + builder.queryParam("decision", decision.name) + reason?.takeIf { it.isNotBlank() }?.let { builder.queryParam("reason", it) } + builder.buildAndExpand(planId) + }) + .retrieve() + .onStatus({ status -> status.isError }, ::mapDecisionError) + .bodyToMono(TguPlanDecisionMessage::class.java) + .block() + ?: throw ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, + TEST_CONTROLLER_UNAVAILABLE_MESSAGE + ) + }.getOrElse { error -> + if (error is WebClientRequestException) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, TEST_CONTROLLER_UNAVAILABLE_MESSAGE, error) + } + throw error + } + + private fun buildUri( + path: String, + customizer: (UriComponentsBuilder) -> Any = { builder -> builder.build() } + ): java.net.URI { + val builder = UriComponentsBuilder.fromUriString(tguServiceUrl.trimEnd('/')).path(path) + val result = customizer(builder) + return when (result) { + is java.net.URI -> result + is org.springframework.web.util.UriComponents -> result.toUri() + else -> builder.build().toUri() + } + } + + private fun mapReadError(response: ClientResponse): Mono = + response.bodyToMono(String::class.java) + .defaultIfEmpty("") + .map { body -> + ResponseStatusException(response.statusCode(), extractErrorMessage(body, "Ошибка pcp-tgu-service")) + } + + private fun mapDecisionError(response: ClientResponse): Mono = + response.bodyToMono(String::class.java) + .defaultIfEmpty("") + .map { body -> + ResponseStatusException(response.statusCode(), decisionErrorMessage(response.statusCode(), body)) + } + + private fun decisionErrorMessage(status: HttpStatusCode, body: String): String = + when (status.value()) { + 404 -> { + val message = extractErrorMessage(body, "") + if (message.contains("Plan", ignoreCase = true) || message.contains("planId", ignoreCase = true)) { + PLAN_NOT_FOUND_MESSAGE + } else { + TEST_CONTROLLER_UNAVAILABLE_MESSAGE + } + } + 409 -> PLAN_NOT_WAITING_MESSAGE + in 500..599 -> TEST_CONTROLLER_UNAVAILABLE_MESSAGE + else -> extractErrorMessage(body, "Не удалось отправить тестовое решение по плану.") + } + + private fun extractErrorMessage(body: String, defaultMessage: String): String { + val text = body.trim() + if (text.isEmpty()) return defaultMessage + + return runCatching { + val node = ObjectMapper().readTree(text) + when { + node.hasNonNull("reason") -> node["reason"].asText() + node.hasNonNull("message") -> node["message"].asText() + node.hasNonNull("error") -> node["error"].asText() + node.hasNonNull("detail") -> node["detail"].asText() + else -> text + } + }.getOrDefault(text) + } + + private companion object { + const val PLAN_NOT_FOUND_MESSAGE = "План не найден." + const val PLAN_NOT_WAITING_MESSAGE = + "План не является активным планом, ожидающим решения, или для него нет active attempt." + const val TEST_CONTROLLER_UNAVAILABLE_MESSAGE = "Test controller недоступен или отключён." + } +} diff --git a/services/pcp-ui-service/src/main/resources/static/css/tgu-planning.css b/services/pcp-ui-service/src/main/resources/static/css/tgu-planning.css new file mode 100644 index 0000000..715c88a --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/css/tgu-planning.css @@ -0,0 +1,169 @@ +.tgu-planning-tabs { + margin-bottom: -0.75rem; +} + +.tgu-chart-controls { + display: flex; + align-items: end; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem; + border-bottom: 1px solid #dbe5ef; +} + +.tgu-chart-controls .form-control { + min-width: 12.5rem; +} + +.tgu-chart-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.55rem 0.85rem; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: #526b83; + font-size: 0.82rem; +} + +.tgu-legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + white-space: nowrap; +} + +.tgu-legend-note { + color: #687f96; +} + +.tgu-legend-swatch { + display: inline-block; + width: 1rem; + height: 0.45rem; + border-radius: 999px; + background: #8d99a6; +} + +.tgu-status-accepted { + background: #198754; +} + +.tgu-status-rejected, +.tgu-status-ambiguous { + background: #dc3545; +} + +.tgu-status-planned { + background: #8d99a6; +} + +.tgu-status-waiting { + background: #f0ad2e; +} + +.tgu-status-issuing { + background: #0d6efd; +} + +.tgu-status-expired { + background: #495057; +} + +.tgu-status-superseded { + background: #c7ced6; +} + +.tgu-status-ambiguous { + border: 1px dashed #7d1f2a; +} + +.tgu-timeline-scroll { + max-height: 64vh; + overflow-y: auto; + overflow-x: hidden; + border-top: 1px solid #edf2f7; + background: #fbfdff; +} + +.tgu-timeline-content { + min-width: 0; +} + +.tgu-timeline-svg { + display: block; + width: 100%; + min-height: 12rem; + background: #fbfdff; +} + +.tgu-timeline-label { + fill: #314963; + font-size: 12px; + font-weight: 600; +} + +.tgu-timeline-sub-label { + fill: #72879d; + font-size: 10px; +} + +.tgu-timeline-axis, +.tgu-timeline-grid { + stroke: #cdd9e5; + stroke-width: 1; +} + +.tgu-timeline-grid { + stroke-dasharray: 3 4; +} + +.tgu-timeline-tick-label { + fill: #5d7288; + font-size: 11px; +} + +.tgu-timeline-plan { + cursor: pointer; + stroke-linecap: round; +} + +.tgu-timeline-plan:hover, +.tgu-timeline-plan-selected { + filter: drop-shadow(0 0 4px rgba(24, 80, 140, 0.45)); +} + +.tgu-sequence-arrow { + stroke: #8ea1b5; + stroke-width: 1.6; + fill: none; +} + +.tgu-plan-details { + border-top: 1px solid #dbe5ef; + padding: 1rem; + background: #ffffff; +} + +.tgu-plan-details:empty { + display: none; +} + +.tgu-plan-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.75rem; +} + +.tgu-plan-detail-label { + color: #6b7f95; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; +} + +.tgu-plan-detail-value { + color: #24364a; + font-size: 0.92rem; + overflow-wrap: anywhere; +} diff --git a/services/pcp-ui-service/src/main/resources/static/tgu_planning_scripts.js b/services/pcp-ui-service/src/main/resources/static/tgu_planning_scripts.js new file mode 100644 index 0000000..933b835 --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/static/tgu_planning_scripts.js @@ -0,0 +1,667 @@ +(() => { + const DAY_MS = 24 * 60 * 60 * 1000; + const TIMELINE_LEFT_WIDTH = 180; + const TIMELINE_RIGHT_PADDING = 24; + const TIMELINE_TOP_PADDING = 24; + const TIMELINE_ROW_HEIGHT = 54; + const TIMELINE_AXIS_HEIGHT = 52; + const TIMELINE_MIN_WIDTH = 760; + + const STATUS_COLORS = { + ACCEPTED: '#198754', + REJECTED: '#dc3545', + PLANNED: '#8d99a6', + WAITING_DECISION: '#f0ad2e', + ISSUING: '#0d6efd', + EXPIRED: '#495057', + SUPERSEDED: '#c7ced6', + START_AMBIGUOUS: '#dc3545' + }; + + const state = { + platforms: [], + plans: [], + selectedNoradId: null, + activeTab: 'table', + loadedHistoryDays: null, + selectedPlanId: null + }; + + const el = (id) => document.getElementById(id); + + document.addEventListener('DOMContentLoaded', () => { + setDefaultTimelineInterval(); + bindEvents(); + reloadAll(); + }); + + function bindEvents() { + el('tgu-refresh')?.addEventListener('click', () => reloadAll()); + el('tgu-show-all')?.addEventListener('click', () => { + state.selectedNoradId = null; + state.selectedPlanId = null; + renderPlatforms(); + updateSelectedPlatformText(); + reloadPlans(); + }); + el('tgu-history-days')?.addEventListener('change', () => reloadPlans()); + document.querySelectorAll('[data-tgu-tab]').forEach((button) => { + button.addEventListener('click', () => switchTab(button.dataset.tguTab)); + }); + el('tgu-chart-show')?.addEventListener('click', () => applyTimelineInterval()); + document.querySelectorAll('[data-tgu-range-hours]').forEach((button) => { + button.addEventListener('click', () => setQuickTimelineRange(Number(button.dataset.tguRangeHours))); + }); + window.addEventListener('resize', () => { + if (state.activeTab === 'chart') renderTimeline(); + }); + } + + async function reloadAll() { + showAlert(''); + await loadPlatforms(); + await reloadPlans(); + } + + async function loadPlatforms() { + setTableLoading('tgu-platforms-body', 4, 'Загрузка платформ...'); + try { + const platforms = await fetchJson('/api/tgu-planning/platforms'); + state.platforms = Array.isArray(platforms) ? platforms : []; + renderPlatforms(); + renderTimeline(); + } catch (error) { + state.platforms = []; + renderPlatforms(); + renderTimeline(); + showAlert(error.message || 'Не удалось загрузить платформы.', 'danger'); + } + } + + async function reloadPlans(historyDaysOverride = null) { + const historyDays = historyDaysOverride || historyDaysForCurrentView(); + const url = state.selectedNoradId + ? `/api/tgu-planning/spacecraft/${encodeURIComponent(state.selectedNoradId)}/plans?historyDays=${historyDays}` + : `/api/tgu-planning/plans?historyDays=${historyDays}`; + setTableLoading('tgu-plans-body', 8, 'Загрузка планов...'); + setTimelineLoading('Загрузка планов.'); + el('tgu-plans-meta').textContent = state.selectedNoradId + ? `Планы для NORAD ${state.selectedNoradId}, история ${historyDays} дн.` + : `Все планы, история ${historyDays} дн.`; + + try { + const plans = await fetchJson(url); + state.loadedHistoryDays = historyDays; + state.plans = Array.isArray(plans) ? plans : []; + if (state.selectedPlanId && !state.plans.some((plan) => String(plan.planId) === String(state.selectedPlanId))) { + state.selectedPlanId = null; + } + renderPlans(); + renderTimeline(); + renderSelectedPlanDetails(); + } catch (error) { + state.plans = []; + state.selectedPlanId = null; + renderPlans(); + renderTimeline(); + renderSelectedPlanDetails(); + showAlert(error.message || 'Не удалось загрузить планы.', 'danger'); + } + } + + function switchTab(tab) { + if (!['table', 'chart'].includes(tab)) return; + state.activeTab = tab; + document.querySelectorAll('[data-tgu-tab]').forEach((button) => { + button.classList.toggle('active', button.dataset.tguTab === tab); + }); + el('tgu-table-panel')?.classList.toggle('d-none', tab !== 'table'); + el('tgu-chart-panel')?.classList.toggle('d-none', tab !== 'chart'); + if (tab === 'chart') { + applyTimelineInterval(); + } + } + + function renderPlatforms() { + const tbody = el('tgu-platforms-body'); + if (!tbody) return; + + if (!state.platforms.length) { + tbody.innerHTML = emptyRow(4, 'Платформы не найдены.'); + return; + } + + tbody.innerHTML = state.platforms.map((platform) => { + const selected = String(platform.noradId || '') === String(state.selectedNoradId || '') ? ' table-active' : ''; + const norad = platform.noradId ?? '—'; + return ` + + ${escapeHtml(platform.name || platform.businessKey || platform.id || '—')} + ${escapeHtml(norad)} + ${escapeHtml(platform.status || '—')} + ${escapeHtml(platform.mission || '—')} + `; + }).join(''); + + tbody.querySelectorAll('tr[data-norad-id]').forEach((row) => { + row.addEventListener('click', () => selectPlatform(row.dataset.noradId)); + }); + } + + function selectPlatform(noradId) { + if (!noradId) { + showAlert('У выбранной платформы нет NORAD ID, фильтрация планов невозможна.', 'warning'); + return; + } + state.selectedNoradId = noradId; + state.selectedPlanId = null; + renderPlatforms(); + updateSelectedPlatformText(); + reloadPlans(); + } + + function renderPlans() { + const tbody = el('tgu-plans-body'); + if (!tbody) return; + + if (!state.plans.length) { + tbody.innerHTML = emptyRow(8, 'Планы не найдены.'); + return; + } + + tbody.innerHTML = state.plans.map((plan) => { + const platform = platformBySpacecraftId(plan.spacecraftId); + return ` + + ${shortUuid(plan.planId)} + ${escapeHtml(plan.spacecraftId || '—')} + ${escapeHtml(platform?.name || '—')} + ${formatDateTime(plan.startTime)} + ${formatDateTime(plan.endTime)} + ${escapeHtml(plan.kppId || '—')} + ${escapeHtml(plan.status || '—')} + ${planActions(plan)} + `; + }).join(''); + + tbody.querySelectorAll('button[data-decision]').forEach((button) => { + button.addEventListener('click', () => sendDecision( + button.dataset.planId, + button.dataset.decision, + button + )); + }); + } + + function applyTimelineInterval() { + const interval = readTimelineInterval(); + if (!interval.valid) { + renderTimelineError(interval.message); + return; + } + + const requiredHistoryDays = historyDaysForTimelineFrom(interval.from); + if (!state.loadedHistoryDays || requiredHistoryDays > state.loadedHistoryDays) { + reloadPlans(requiredHistoryDays); + return; + } + + renderTimeline(); + renderSelectedPlanDetails(); + } + + function setQuickTimelineRange(hours) { + if (!Number.isFinite(hours) || hours <= 0) return; + const from = startOfToday(); + const to = new Date(from.getTime() + hours * 60 * 60 * 1000); + el('tgu-chart-from').value = toDatetimeLocalValue(from); + el('tgu-chart-to').value = toDatetimeLocalValue(to); + applyTimelineInterval(); + } + + function renderTimeline() { + if (!el('tgu-timeline-content')) return; + + const interval = readTimelineInterval(); + if (!interval.valid) { + renderTimelineError(interval.message); + return; + } + clearTimelineError(); + + const visiblePlans = state.plans + .map((plan) => ({plan, start: parsePlanDate(plan.startTime), end: parsePlanDate(plan.endTime)})) + .filter((item) => item.start && item.end && item.end.getTime() >= interval.from.getTime() && item.start.getTime() <= interval.to.getTime()) + .sort((left, right) => { + const spacecraftCompare = String(left.plan.spacecraftId || '').localeCompare(String(right.plan.spacecraftId || '')); + return spacecraftCompare || left.start.getTime() - right.start.getTime(); + }); + + if (!visiblePlans.length) { + el('tgu-timeline-content').innerHTML = '
Нет планов в выбранном интервале
'; + return; + } + + const grouped = groupBySpacecraft(visiblePlans); + const scroll = el('tgu-timeline-scroll'); + const svgWidth = Math.max(scroll?.clientWidth || TIMELINE_MIN_WIDTH, TIMELINE_MIN_WIDTH); + const chartWidth = svgWidth - TIMELINE_LEFT_WIDTH - TIMELINE_RIGHT_PADDING; + const svgHeight = TIMELINE_TOP_PADDING + grouped.length * TIMELINE_ROW_HEIGHT + TIMELINE_AXIS_HEIGHT; + const axisY = TIMELINE_TOP_PADDING + grouped.length * TIMELINE_ROW_HEIGHT + 14; + const ticks = timelineTicks(interval.from, interval.to); + const intervalMs = interval.to.getTime() - interval.from.getTime(); + const xForTime = (time) => { + // Координата считается от выбранного интервала, а не от фактически загруженной истории. + return TIMELINE_LEFT_WIDTH + ((time.getTime() - interval.from.getTime()) / intervalMs) * chartWidth; + }; + + const rows = grouped.map((group, index) => { + const rowY = TIMELINE_TOP_PADDING + index * TIMELINE_ROW_HEIGHT + 24; + const label = spacecraftLabel(group.spacecraftId); + const subLabel = label === String(group.spacecraftId) ? '' : String(group.spacecraftId); + const sortedPlans = group.items.sort((left, right) => left.start.getTime() - right.start.getTime()); + const planLines = sortedPlans.map((item) => { + const x1 = Math.max(TIMELINE_LEFT_WIDTH, xForTime(item.start)); + const x2 = Math.min(TIMELINE_LEFT_WIDTH + chartWidth, xForTime(item.end)); + const widthAdjustedX2 = x2 - x1 < 4 ? Math.min(TIMELINE_LEFT_WIDTH + chartWidth, x1 + 4) : x2; + const status = String(item.plan.status || ''); + const selected = String(item.plan.planId) === String(state.selectedPlanId); + const dashArray = status === 'START_AMBIGUOUS' ? '7 5' : ''; + return ` + + ${escapeHtml(planTooltip(item.plan))} + `; + }).join(''); + const arrows = sortedPlans.slice(0, -1).map((item, planIndex) => { + const next = sortedPlans[planIndex + 1]; + const startX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 8, Math.max(TIMELINE_LEFT_WIDTH + 8, xForTime(item.end) + 7)); + const endX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 4, Math.max(TIMELINE_LEFT_WIDTH + 12, xForTime(next.start) - 7)); + if (endX - startX >= 16) { + return ``; + } + const markerX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 20, Math.max(TIMELINE_LEFT_WIDTH + 20, xForTime(next.start))); + return ``; + }).join(''); + return ` + + + + ${escapeHtml(String(group.spacecraftId))}${escapeHtml(label)} + + ${subLabel ? `${escapeHtml(subLabel)}` : ''} + ${arrows} + ${planLines} + `; + }).join(''); + + const tickMarkup = ticks.map((tick) => { + const x = xForTime(tick); + return ` + + + + ${escapeHtml(formatAxisTick(tick))} + `; + }).join(''); + + el('tgu-timeline-content').innerHTML = ` + + + + + + + Последовательность планов по времени + ${tickMarkup} + + ${rows} + `; + + el('tgu-timeline-content').querySelectorAll('[data-plan-id]').forEach((line) => { + line.addEventListener('click', () => { + state.selectedPlanId = line.dataset.planId; + renderTimeline(); + renderSelectedPlanDetails(); + }); + }); + } + + function renderSelectedPlanDetails() { + const target = el('tgu-plan-details'); + if (!target) return; + const plan = state.plans.find((item) => String(item.planId) === String(state.selectedPlanId)); + if (!plan) { + target.innerHTML = ''; + return; + } + + const platform = platformBySpacecraftId(plan.spacecraftId); + target.innerHTML = ` +
+
+
Детали плана
+
${escapeHtml(platform?.name || plan.spacecraftId || '—')}
+
+ ${planActions(plan)} +
+
+ ${detailItem('planId', plan.planId)} + ${detailItem('spacecraftId', plan.spacecraftId)} + ${detailItem('startTime', formatDateTime(plan.startTime))} + ${detailItem('endTime', formatDateTime(plan.endTime))} + ${detailItem('kppId', plan.kppId || '—')} + ${detailItem('status', plan.status || '—')} +
`; + + target.querySelectorAll('button[data-decision]').forEach((button) => { + button.addEventListener('click', () => sendDecision( + button.dataset.planId, + button.dataset.decision, + button + )); + }); + } + + function renderTimelineError(message) { + const error = el('tgu-chart-error'); + if (error) { + error.textContent = message; + error.classList.remove('d-none'); + } + const target = el('tgu-timeline-content'); + if (target) target.innerHTML = ''; + } + + function clearTimelineError() { + const error = el('tgu-chart-error'); + if (!error) return; + error.textContent = ''; + error.classList.add('d-none'); + } + + function setTimelineLoading(message) { + const target = el('tgu-timeline-content'); + if (target) target.innerHTML = `
${escapeHtml(message)}
`; + } + + function planActions(plan) { + if (plan.status !== 'WAITING_DECISION') return ''; + const planId = escapeHtml(plan.planId); + return ` +
+ + +
`; + } + + async function sendDecision(planId, decision, button) { + let reason = decision === 'ACCEPTED' ? 'UI_TEST_ACCEPTED' : ''; + if (decision === 'REJECTED') { + reason = window.prompt('Причина отклонения плана', 'UI_TEST_REJECTED'); + if (reason === null) return; + reason = reason.trim(); + if (!reason) { + showAlert('Для REJECTED укажите reason.', 'warning'); + return; + } + } + + setButtonLoading(button, true, 'Отправка...'); + try { + const query = new URLSearchParams({decision, reason}); + const result = await fetchJson(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`, { + method: 'POST', + headers: {'Accept': 'application/json'}, + decisionRequest: true + }); + const attempt = result?.attemptId ? ` attemptId=${result.attemptId}` : ''; + const message = decision === 'REJECTED' + ? `Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи.${attempt}` + : `Тестовое решение ACCEPTED отправлено.${attempt}`; + showAlert(message, decision === 'REJECTED' ? 'warning' : 'success'); + await reloadPlans(state.loadedHistoryDays || selectedHistoryDays()); + } catch (error) { + showAlert(error.message || 'Не удалось отправить тестовое решение по плану.', 'danger'); + } finally { + setButtonLoading(button, false); + } + } + + function selectedHistoryDays() { + const value = Number(el('tgu-history-days')?.value || 7); + return [1, 3, 7, 14].includes(value) ? value : 7; + } + + function historyDaysForCurrentView() { + const selected = selectedHistoryDays(); + if (state.activeTab !== 'chart') return selected; + const interval = readTimelineInterval(); + return interval.valid ? Math.max(selected, historyDaysForTimelineFrom(interval.from)) : selected; + } + + function historyDaysForTimelineFrom(from) { + const now = new Date(); + if (from.getTime() >= now.getTime()) return 1; + return Math.max(1, Math.ceil((now.getTime() - from.getTime()) / DAY_MS)); + } + + function setDefaultTimelineInterval() { + const from = startOfToday(); + const to = new Date(from.getTime() + DAY_MS); + if (el('tgu-chart-from')) el('tgu-chart-from').value = toDatetimeLocalValue(from); + if (el('tgu-chart-to')) el('tgu-chart-to').value = toDatetimeLocalValue(to); + } + + function readTimelineInterval() { + const from = parseDatetimeLocalValue(el('tgu-chart-from')?.value); + const to = parseDatetimeLocalValue(el('tgu-chart-to')?.value); + if (!from || !to) return {valid: false, message: 'Укажите from и to.'}; + if (from.getTime() >= to.getTime()) return {valid: false, message: 'from должен быть раньше to.'}; + return {valid: true, from, to}; + } + + function parseDatetimeLocalValue(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + function parsePlanDate(value) { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + } + + function startOfToday() { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); + } + + function toDatetimeLocalValue(date) { + const pad = (value) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + } + + function timelineTicks(from, to) { + const duration = to.getTime() - from.getTime(); + const tickCount = duration <= DAY_MS ? 6 : 7; + const ticks = []; + for (let index = 0; index <= tickCount; index += 1) { + ticks.push(new Date(from.getTime() + (duration * index) / tickCount)); + } + return ticks; + } + + function groupBySpacecraft(items) { + const groups = new Map(); + items.forEach((item) => { + const spacecraftId = item.plan.spacecraftId || '—'; + if (!groups.has(spacecraftId)) groups.set(spacecraftId, []); + groups.get(spacecraftId).push(item); + }); + return Array.from(groups.entries()).map(([spacecraftId, groupItems]) => ({spacecraftId, items: groupItems})); + } + + function spacecraftLabel(spacecraftId) { + const platform = platformBySpacecraftId(spacecraftId); + return platform?.name || String(spacecraftId || '—'); + } + + function statusColor(status) { + return STATUS_COLORS[status] || '#8d99a6'; + } + + function planTooltip(plan) { + return [ + `planId: ${plan.planId || '—'}`, + `spacecraftId: ${plan.spacecraftId || '—'}`, + `startTime: ${plan.startTime || '—'}`, + `endTime: ${plan.endTime || '—'}`, + `kppId: ${plan.kppId || '—'}`, + `status: ${plan.status || '—'}` + ].join('\n'); + } + + function detailItem(label, value) { + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
`; + } + + function updateSelectedPlatformText() { + if (!state.selectedNoradId) { + el('tgu-planning-selected-platform').textContent = 'Все планы по доступным платформам.'; + return; + } + const platform = platformBySpacecraftId(state.selectedNoradId); + const name = platform?.name || 'платформа'; + el('tgu-planning-selected-platform').textContent = `${name}, NORAD ${state.selectedNoradId}.`; + } + + function platformBySpacecraftId(spacecraftId) { + return state.platforms.find((platform) => String(platform.noradId) === String(spacecraftId)) || null; + } + + async function fetchJson(url, options = {}) { + const decisionRequest = Boolean(options.decisionRequest); + const requestOptions = {...options}; + delete requestOptions.decisionRequest; + const response = await fetch( + url, + requestOptions.headers ? requestOptions : {...requestOptions, headers: {'Accept': 'application/json'}} + ); + if (!response.ok) { + const text = await response.text(); + throw new Error(errorMessage(response.status, text, decisionRequest)); + } + if (response.status === 204) return null; + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + function errorMessage(status, text, decisionRequest) { + if (decisionRequest && status === 409) { + return 'План не является активным планом, ожидающим решения, или для него нет active attempt.'; + } + if (decisionRequest && status === 404) { + const extracted = extractError(text); + return extracted === 'Test controller недоступен или отключён.' ? extracted : 'План не найден.'; + } + if (decisionRequest && status >= 500) { + const extracted = extractError(text); + return extracted || 'Test controller недоступен или отключён.'; + } + return extractError(text) || `HTTP ${status}`; + } + + function extractError(text) { + if (!text) return ''; + try { + const json = JSON.parse(text); + return json.reason || json.message || json.error || json.detail || text; + } catch (_) { + return text; + } + } + + function showAlert(message, type = 'danger') { + const target = el('tgu-planning-alert'); + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + target.innerHTML = ``; + } + + function setTableLoading(bodyId, colspan, message) { + const tbody = el(bodyId); + if (tbody) tbody.innerHTML = emptyRow(colspan, message); + } + + function emptyRow(colspan, message) { + return `${escapeHtml(message)}`; + } + + function shortUuid(value) { + if (!value) return '—'; + return `${String(value).slice(0, 8)}...`; + } + + function formatDateTime(value) { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return escapeHtml(value); + return date.toLocaleString('ru-RU', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + } + + function formatAxisTick(value) { + return value.toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + } + + function setButtonLoading(button, loading, text = 'Загрузка...') { + if (!button) return; + if (loading) { + button.dataset.originalText = button.textContent; + button.textContent = text; + button.disabled = true; + } else { + button.textContent = button.dataset.originalText || button.textContent; + button.disabled = false; + } + } + + function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } +})(); diff --git a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html index 2af2716..7a26ad9 100644 --- a/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html +++ b/services/pcp-ui-service/src/main/resources/templates/fragments/base/menu.html @@ -71,6 +71,9 @@ + diff --git a/services/pcp-ui-service/src/main/resources/templates/tgu-planning.html b/services/pcp-ui-service/src/main/resources/templates/tgu-planning.html new file mode 100644 index 0000000..da98aaa --- /dev/null +++ b/services/pcp-ui-service/src/main/resources/templates/tgu-planning.html @@ -0,0 +1,149 @@ + + + + Планирование ТГУ + + + + + +
+
+
+

Планирование ТГУ

+
Все планы по доступным платформам.
+
+
+
+ + +
+ + +
+
+ +
+ +
+
+
+
+
Платформы
+
Выберите строку с NORAD для фильтрации планов.
+
+
+ + + + + + + + + + +
ПлатформаNORADСтатусМиссия
+
+
+
+ +
+
+
+
+
+
Планы ТГУ
+
Загрузка планов.
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + +
planIdspacecraftIdПлатформаНачалоКонецkppIdСтатусДействия
+
+
+ +
+
+
+ + +
+
+ + +
+ +
+ + + +
+
+ +
+ +
+ ACCEPTED + REJECTED + PLANNED + WAITING_DECISION + ISSUING + EXPIRED + SUPERSEDED + START_AMBIGUOUS + Стрелки показывают следующий план того же КА по времени. +
+ +
+
+ Загрузка планов. +
+
+
+
+
+
+
+
+
+ + +
+ + diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt index e22cffa..2e062de 100644 --- a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/controller/CatalogControllerTest.kt @@ -26,6 +26,7 @@ import space.nstart.pcp.slots_service.dto.groupstate.GroupStateSatelliteDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmAvailabilityDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmDetailsDTO import space.nstart.pcp.slots_service.dto.pdcm.SatellitePdcmSummaryDTO +import space.nstart.pcp.slots_service.dto.tgu.TguPlanResponse import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCalculationSummaryDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteCreateDTO import space.nstart.pcp.pcp_types_lib.dto.satellite.SatelliteDTO @@ -38,6 +39,7 @@ import space.nstart.pcp.slots_service.service.SatelliteCatalogService import space.nstart.pcp.slots_service.service.SatellitePdcmService import space.nstart.pcp.slots_service.service.SlotService import space.nstart.pcp.slots_service.service.StationService +import space.nstart.pcp.slots_service.service.TguPlanningService import java.time.LocalDateTime import java.util.UUID import kotlin.test.assertEquals @@ -72,6 +74,9 @@ class CatalogControllerTest { @MockitoBean private lateinit var satellitePdcmService: SatellitePdcmService + @MockitoBean + private lateinit var tguPlanningService: TguPlanningService + @Test fun `groups page is rendered`() { val body = WebClient.create("http://localhost:$port") @@ -155,6 +160,48 @@ class CatalogControllerTest { assertFalse(body.contains("UUID")) } + @Test + fun `tgu planning page is rendered from menu`() { + val body = WebClient.create("http://localhost:$port") + .get() + .uri("/tgu-planning") + .retrieve() + .bodyToMono(String::class.java) + .block()!! + + assertTrue(body.contains("Планирование ТГУ")) + assertTrue(body.contains("/tgu_planning_scripts.js")) + assertTrue(body.contains("href=\"/tgu-planning\"")) + } + + @Test + fun `tgu spacecraft plans endpoint proxies norad spacecraft id`() { + val planId = UUID.fromString("6d0941e4-61d6-4f96-8fa8-90ba11db121e") + val response = listOf( + TguPlanResponse( + planId = planId, + spacecraftId = "56756", + startTime = LocalDateTime.of(2026, 5, 29, 10, 0), + endTime = LocalDateTime.of(2026, 5, 29, 10, 15), + kppId = "KPP-1", + status = "WAITING_DECISION" + ) + ) + doReturn(response).`when`(tguPlanningService).spacecraftPlans("56756", 14L) + + val actual = WebClient.create("http://localhost:$port") + .get() + .uri("/api/tgu-planning/spacecraft/56756/plans?historyDays=14") + .retrieve() + .bodyToFlux(TguPlanResponse::class.java) + .collectList() + .block()!! + + assertEquals(planId, actual.single().planId) + assertEquals("56756", actual.single().spacecraftId) + verify(tguPlanningService).spacecraftPlans("56756", 14L) + } + @Test fun `group create endpoint proxies request`() { val response = SatelliteGroupDTO( diff --git a/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt new file mode 100644 index 0000000..9cc4328 --- /dev/null +++ b/services/pcp-ui-service/src/test/kotlin/space/nstart/pcp/slots_service/service/TguPlanningServiceTest.kt @@ -0,0 +1,140 @@ +package space.nstart.pcp.slots_service.service + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.beans.factory.ObjectProvider +import org.springframework.http.HttpStatus +import org.springframework.test.util.ReflectionTestUtils +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp.slots_service.dto.tgu.TguPlanDecision +import java.net.InetSocketAddress +import java.net.URI +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.test.assertEquals + +class TguPlanningServiceTest { + + private var server: HttpServer? = null + + @AfterEach + fun tearDown() { + server?.stop(0) + } + + @Test + fun `read methods call existing tgu endpoints`() { + val requests = CopyOnWriteArrayList() + val service = createService(startServer(requests)) + + val platforms = service.platforms() + val plans = service.plans(7) + val spacecraftPlans = service.spacecraftPlans("56756", 14) + + assertEquals("Kondor-FKA No.2", platforms.single().name) + assertEquals("56756", plans.single().spacecraftId) + assertEquals("WAITING_DECISION", spacecraftPlans.single().status) + assertEquals( + listOf("/api/platforms", "/api/plans", "/api/plans/56756"), + requests.map { it.path } + ) + assertEquals(listOf(null, "historyDays=7", "historyDays=14"), requests.map { it.rawQuery }) + } + + @Test + fun `decision method calls test controller endpoint`() { + val requests = CopyOnWriteArrayList() + val service = createService(startServer(requests)) + + val response = service.sendDecision( + java.util.UUID.fromString("6d0941e4-61d6-4f96-8fa8-90ba11db121e"), + TguPlanDecision.ACCEPTED, + "UI TEST ACCEPTED" + ) + + assertEquals(TguPlanDecision.ACCEPTED, response.decision) + assertEquals("/test/tgu/plans/6d0941e4-61d6-4f96-8fa8-90ba11db121e/decision", requests.last().path) + assertEquals("decision=ACCEPTED&reason=UI%20TEST%20ACCEPTED", requests.last().rawQuery) + } + + @Test + fun `decision conflict is mapped to operator message`() { + val service = createService( + startServer( + statusByPath = mapOf( + "/test/tgu/plans/00000000-0000-0000-0000-000000000000/decision" to 409 + ) + ) + ) + + val exception = assertThrows { + service.sendDecision( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000000"), + TguPlanDecision.REJECTED, + "reason" + ) + } + + assertEquals(HttpStatus.CONFLICT, exception.statusCode) + assertEquals( + "План не является активным планом, ожидающим решения, или для него нет active attempt.", + exception.reason + ) + } + + private fun createService(serverUrl: String): TguPlanningService { + val provider = object : ObjectProvider { + override fun getObject(vararg args: Any?): WebClient.Builder = WebClient.builder() + override fun getObject(): WebClient.Builder = WebClient.builder() + override fun getIfAvailable(): WebClient.Builder = WebClient.builder() + override fun getIfUnique(): WebClient.Builder = WebClient.builder() + override fun iterator(): MutableIterator = mutableListOf().iterator() + } + val service = TguPlanningService(provider) + ReflectionTestUtils.setField(service, "tguServiceUrl", serverUrl) + return service + } + + private fun startServer( + requests: MutableList = CopyOnWriteArrayList(), + statusByPath: Map = emptyMap() + ): String { + val startedServer = HttpServer.create(InetSocketAddress(0), 0) + startedServer.createContext("/") { exchange -> + requests.add(exchange.requestURI) + val forcedStatus = statusByPath[exchange.requestURI.path] + when { + forcedStatus != null -> respond(exchange, forcedStatus, """{"error":"conflict"}""") + exchange.requestURI.path == "/api/platforms" -> respond( + exchange, + 200, + """[{"id":"nsi-id","businessKey":"2024-149E","name":"Kondor-FKA No.2","status":"OPERATIONAL","noradId":56756,"mission":"KONDOR_FKA","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z"}]""" + ) + exchange.requestURI.path == "/api/plans" || exchange.requestURI.path == "/api/plans/56756" -> respond( + exchange, + 200, + """[{"planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","spacecraftId":"56756","startTime":"2026-05-29T10:00:00","endTime":"2026-05-29T10:15:00","kppId":"KPP-1","status":"WAITING_DECISION"}]""" + ) + exchange.requestURI.path.startsWith("/test/tgu/plans/") -> respond( + exchange, + 200, + """{"eventId":"1c857555-f02b-4051-b56a-f112b842624a","spacecraftId":"56756","planId":"6d0941e4-61d6-4f96-8fa8-90ba11db121e","attemptId":"a82394f2-545f-4cd6-9ab1-876ca403d33a","decision":"ACCEPTED","decisionTime":"2026-05-29T10:01:00","reason":"UI TEST ACCEPTED"}""" + ) + else -> respond(exchange, 404, """{"error":"not found"}""") + } + } + startedServer.start() + server = startedServer + return "http://localhost:${startedServer.address.port}" + } + + private fun respond(exchange: HttpExchange, status: Int, body: String) { + exchange.responseHeaders.add("Content-Type", "application/json") + val bytes = body.toByteArray() + exchange.sendResponseHeaders(status, bytes.size.toLong()) + exchange.responseBody.use { output -> output.write(bytes) } + } +} diff --git a/services/pcp-ui-service/src/test/resources/application.yaml b/services/pcp-ui-service/src/test/resources/application.yaml index 9d2a49d..daac739 100644 --- a/services/pcp-ui-service/src/test/resources/application.yaml +++ b/services/pcp-ui-service/src/test/resources/application.yaml @@ -13,3 +13,4 @@ spring: settings: dynamic-plan-service: http://localhost:65535 + tgu-service: http://localhost:65535