pcp-tgu интерфейс
This commit is contained in:
@@ -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}
|
||||
|
||||
+7
-1
@@ -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<Map<String, String>> {
|
||||
return ResponseEntity.status(ex.statusCode)
|
||||
.body(mapOf("error" to (ex.reason ?: ex.message)))
|
||||
}
|
||||
@ExceptionHandler(WebExchangeBindException::class)
|
||||
fun handleValidationExceptions(ex: WebExchangeBindException): Mono<ResponseEntity<Map<String, String>>> {
|
||||
val errors = mutableMapOf<String, String>()
|
||||
@@ -43,4 +49,4 @@ class GlobalExceptionHandler {
|
||||
}
|
||||
return ResponseEntity.badRequest().body(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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)
|
||||
}
|
||||
+40
@@ -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?
|
||||
)
|
||||
+154
@@ -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<WebClient.Builder>) {
|
||||
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
|
||||
|
||||
@Value("\${settings.tgu-service:pcp-tgu-service}")
|
||||
private lateinit var tguServiceUrl: String
|
||||
|
||||
fun platforms(): List<TguPlatformResponse> =
|
||||
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<TguPlanResponse> =
|
||||
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<TguPlanResponse> =
|
||||
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<Throwable> =
|
||||
response.bodyToMono(String::class.java)
|
||||
.defaultIfEmpty("")
|
||||
.map { body ->
|
||||
ResponseStatusException(response.statusCode(), extractErrorMessage(body, "Ошибка pcp-tgu-service"))
|
||||
}
|
||||
|
||||
private fun mapDecisionError(response: ClientResponse): Mono<Throwable> =
|
||||
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 недоступен или отключён."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 `
|
||||
<tr class="${selected}" data-norad-id="${escapeHtml(platform.noradId || '')}">
|
||||
<td>${escapeHtml(platform.name || platform.businessKey || platform.id || '—')}</td>
|
||||
<td>${escapeHtml(norad)}</td>
|
||||
<td><span class="badge text-bg-light">${escapeHtml(platform.status || '—')}</span></td>
|
||||
<td>${escapeHtml(platform.mission || '—')}</td>
|
||||
</tr>`;
|
||||
}).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 `
|
||||
<tr>
|
||||
<td title="${escapeHtml(plan.planId)}">${shortUuid(plan.planId)}</td>
|
||||
<td>${escapeHtml(plan.spacecraftId || '—')}</td>
|
||||
<td>${escapeHtml(platform?.name || '—')}</td>
|
||||
<td>${formatDateTime(plan.startTime)}</td>
|
||||
<td>${formatDateTime(plan.endTime)}</td>
|
||||
<td>${escapeHtml(plan.kppId || '—')}</td>
|
||||
<td><span class="badge text-bg-light">${escapeHtml(plan.status || '—')}</span></td>
|
||||
<td>${planActions(plan)}</td>
|
||||
</tr>`;
|
||||
}).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 = '<div class="text-muted text-center py-4">Нет планов в выбранном интервале</div>';
|
||||
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 `
|
||||
<line class="tgu-timeline-plan${selected ? ' tgu-timeline-plan-selected' : ''}"
|
||||
x1="${x1.toFixed(2)}" y1="${rowY}" x2="${widthAdjustedX2.toFixed(2)}" y2="${rowY}"
|
||||
stroke="${escapeHtml(statusColor(status))}" stroke-width="${selected ? 15 : 11}"
|
||||
${dashArray ? `stroke-dasharray="${dashArray}"` : ''}
|
||||
data-plan-id="${escapeHtml(item.plan.planId)}">
|
||||
<title>${escapeHtml(planTooltip(item.plan))}</title>
|
||||
</line>`;
|
||||
}).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 `<line class="tgu-sequence-arrow" x1="${startX.toFixed(2)}" y1="${rowY - 13}" x2="${endX.toFixed(2)}" y2="${rowY - 13}" marker-end="url(#tgu-arrowhead)" />`;
|
||||
}
|
||||
const markerX = Math.min(TIMELINE_LEFT_WIDTH + chartWidth - 20, Math.max(TIMELINE_LEFT_WIDTH + 20, xForTime(next.start)));
|
||||
return `<path class="tgu-sequence-arrow" d="M ${markerX - 12} ${rowY - 16} C ${markerX - 2} ${rowY - 26}, ${markerX + 16} ${rowY - 22}, ${markerX + 12} ${rowY - 8}" marker-end="url(#tgu-arrowhead)" />`;
|
||||
}).join('');
|
||||
return `
|
||||
<g>
|
||||
<line class="tgu-timeline-grid" x1="${TIMELINE_LEFT_WIDTH}" y1="${rowY + 24}" x2="${TIMELINE_LEFT_WIDTH + chartWidth}" y2="${rowY + 24}" />
|
||||
<text class="tgu-timeline-label" x="12" y="${rowY - 4}">
|
||||
<title>${escapeHtml(String(group.spacecraftId))}</title>${escapeHtml(label)}
|
||||
</text>
|
||||
${subLabel ? `<text class="tgu-timeline-sub-label" x="12" y="${rowY + 12}">${escapeHtml(subLabel)}</text>` : ''}
|
||||
${arrows}
|
||||
${planLines}
|
||||
</g>`;
|
||||
}).join('');
|
||||
|
||||
const tickMarkup = ticks.map((tick) => {
|
||||
const x = xForTime(tick);
|
||||
return `
|
||||
<g>
|
||||
<line class="tgu-timeline-grid" x1="${x.toFixed(2)}" y1="${TIMELINE_TOP_PADDING - 8}" x2="${x.toFixed(2)}" y2="${axisY}" />
|
||||
<line class="tgu-timeline-axis" x1="${x.toFixed(2)}" y1="${axisY}" x2="${x.toFixed(2)}" y2="${axisY + 6}" />
|
||||
<text class="tgu-timeline-tick-label" x="${x.toFixed(2)}" y="${axisY + 22}" text-anchor="middle">${escapeHtml(formatAxisTick(tick))}</text>
|
||||
</g>`;
|
||||
}).join('');
|
||||
|
||||
el('tgu-timeline-content').innerHTML = `
|
||||
<svg class="tgu-timeline-svg" viewBox="0 0 ${svgWidth} ${svgHeight}" role="img" aria-label="Последовательность планов по времени">
|
||||
<defs>
|
||||
<marker id="tgu-arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,4 L0,8 Z" fill="#8ea1b5"></path>
|
||||
</marker>
|
||||
</defs>
|
||||
<text class="tgu-timeline-label" x="${TIMELINE_LEFT_WIDTH}" y="16">Последовательность планов по времени</text>
|
||||
${tickMarkup}
|
||||
<line class="tgu-timeline-axis" x1="${TIMELINE_LEFT_WIDTH}" y1="${axisY}" x2="${TIMELINE_LEFT_WIDTH + chartWidth}" y2="${axisY}" />
|
||||
${rows}
|
||||
</svg>`;
|
||||
|
||||
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 = `
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<div class="catalog-section-title">Детали плана</div>
|
||||
<div class="catalog-helper mt-1">${escapeHtml(platform?.name || plan.spacecraftId || '—')}</div>
|
||||
</div>
|
||||
${planActions(plan)}
|
||||
</div>
|
||||
<div class="tgu-plan-details-grid">
|
||||
${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 || '—')}
|
||||
</div>`;
|
||||
|
||||
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 = `<div class="text-muted text-center py-4">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function planActions(plan) {
|
||||
if (plan.status !== 'WAITING_DECISION') return '<span class="text-muted">—</span>';
|
||||
const planId = escapeHtml(plan.planId);
|
||||
return `
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-outline-success btn-sm" data-decision="ACCEPTED" data-plan-id="${planId}">
|
||||
Принять
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-decision="REJECTED" data-plan-id="${planId}">
|
||||
Отклонить
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div>
|
||||
<div class="tgu-plan-detail-label">${escapeHtml(label)}</div>
|
||||
<div class="tgu-plan-detail-value">${escapeHtml(value)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${escapeHtml(message)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function setTableLoading(bodyId, colspan, message) {
|
||||
const tbody = el(bodyId);
|
||||
if (tbody) tbody.innerHTML = emptyRow(colspan, message);
|
||||
}
|
||||
|
||||
function emptyRow(colspan, message) {
|
||||
return `<tr><td colspan="${colspan}" class="text-muted text-center py-4">${escapeHtml(message)}</td></tr>`;
|
||||
}
|
||||
|
||||
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("'", ''');
|
||||
}
|
||||
})();
|
||||
@@ -71,6 +71,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/current-plans">Текущие планы</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/tgu-planning">Планирование ТГУ</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE HTML>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.w3.org/1999/xhtml"
|
||||
layout:decorate="~{layouts/main-layout}">
|
||||
<head>
|
||||
<title>Планирование ТГУ</title>
|
||||
<link rel="stylesheet" href="/css/catalog.css"/>
|
||||
<link rel="stylesheet" href="/css/tgu-planning.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<th:block layout:fragment="content">
|
||||
<div id="tgu-planning-page" class="catalog-page py-3">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Планирование ТГУ</h2>
|
||||
<div id="tgu-planning-selected-platform" class="catalog-helper">Все планы по доступным платформам.</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-end gap-2 flex-wrap">
|
||||
<div>
|
||||
<label for="tgu-history-days" class="form-label mb-1">История, дней</label>
|
||||
<select id="tgu-history-days" class="form-select form-select-sm">
|
||||
<option value="1">1</option>
|
||||
<option value="3">3</option>
|
||||
<option value="7" selected>7</option>
|
||||
<option value="14">14</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="tgu-show-all" type="button" class="btn btn-outline-secondary btn-sm">Все планы</button>
|
||||
<button id="tgu-refresh" type="button" class="btn btn-primary btn-sm">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tgu-planning-alert"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card catalog-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="catalog-section-title">Платформы</div>
|
||||
<div class="catalog-helper mt-1">Выберите строку с NORAD для фильтрации планов.</div>
|
||||
</div>
|
||||
<div class="card-body catalog-list p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Платформа</th>
|
||||
<th>NORAD</th>
|
||||
<th>Статус</th>
|
||||
<th>Миссия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tgu-platforms-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card catalog-card h-100">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="catalog-section-title">Планы ТГУ</div>
|
||||
<div id="tgu-plans-meta" class="catalog-helper mt-1">Загрузка планов.</div>
|
||||
</div>
|
||||
<ul class="nav nav-tabs card-header-tabs tgu-planning-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button id="tgu-tab-table" type="button" class="nav-link active" data-tgu-tab="table" role="tab">
|
||||
Таблица
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button id="tgu-tab-chart" type="button" class="nav-link" data-tgu-tab="chart" role="tab">
|
||||
График
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="tgu-table-panel" class="tgu-tab-panel">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>planId</th>
|
||||
<th>spacecraftId</th>
|
||||
<th>Платформа</th>
|
||||
<th>Начало</th>
|
||||
<th>Конец</th>
|
||||
<th>kppId</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tgu-plans-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tgu-chart-panel" class="tgu-tab-panel d-none">
|
||||
<div class="tgu-chart-controls">
|
||||
<div>
|
||||
<label for="tgu-chart-from" class="form-label mb-1">from</label>
|
||||
<input id="tgu-chart-from" type="datetime-local" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgu-chart-to" class="form-label mb-1">to</label>
|
||||
<input id="tgu-chart-to" type="datetime-local" class="form-control form-control-sm"/>
|
||||
</div>
|
||||
<button id="tgu-chart-show" type="button" class="btn btn-primary btn-sm">Показать</button>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Быстрый выбор интервала">
|
||||
<button type="button" class="btn btn-outline-secondary" data-tgu-range-hours="24">24ч</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-tgu-range-hours="72">3 суток</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-tgu-range-hours="168">7 суток</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tgu-chart-error" class="text-danger small px-3 pb-2 d-none"></div>
|
||||
|
||||
<div class="tgu-chart-legend px-3">
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-accepted"></span>ACCEPTED</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-rejected"></span>REJECTED</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-planned"></span>PLANNED</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-waiting"></span>WAITING_DECISION</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-issuing"></span>ISSUING</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-expired"></span>EXPIRED</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-superseded"></span>SUPERSEDED</span>
|
||||
<span class="tgu-legend-item"><span class="tgu-legend-swatch tgu-status-ambiguous"></span>START_AMBIGUOUS</span>
|
||||
<span class="tgu-legend-note">Стрелки показывают следующий план того же КА по времени.</span>
|
||||
</div>
|
||||
|
||||
<div id="tgu-timeline-scroll" class="tgu-timeline-scroll">
|
||||
<div id="tgu-timeline-content" class="tgu-timeline-content text-muted text-center py-4">
|
||||
Загрузка планов.
|
||||
</div>
|
||||
</div>
|
||||
<div id="tgu-plan-details" class="tgu-plan-details"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/tgu_planning_scripts.js"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
+47
@@ -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("<th>UUID</th>"))
|
||||
}
|
||||
|
||||
@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(
|
||||
|
||||
+140
@@ -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<URI>()
|
||||
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<URI>()
|
||||
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<ResponseStatusException> {
|
||||
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<WebClient.Builder> {
|
||||
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<WebClient.Builder> = mutableListOf<WebClient.Builder>().iterator()
|
||||
}
|
||||
val service = TguPlanningService(provider)
|
||||
ReflectionTestUtils.setField(service, "tguServiceUrl", serverUrl)
|
||||
return service
|
||||
}
|
||||
|
||||
private fun startServer(
|
||||
requests: MutableList<URI> = CopyOnWriteArrayList(),
|
||||
statusByPath: Map<String, Int> = 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) }
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,4 @@ spring:
|
||||
|
||||
settings:
|
||||
dynamic-plan-service: http://localhost:65535
|
||||
tgu-service: http://localhost:65535
|
||||
|
||||
Reference in New Issue
Block a user