Новый сервис pcp-tgu-ui-service: ТГУ-фронт на headless-auth nstart + платформенный CI/деплой
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
TguApiError,
|
||||
type TguPlan,
|
||||
type TguPlanDecision,
|
||||
type TguPlanDecisionMessage,
|
||||
type TguPlatform,
|
||||
} from '../model/tguTypes'
|
||||
import { toLocalIso } from '../utils/localTime'
|
||||
import { apiFetch } from './apiFetch'
|
||||
|
||||
export type FlightLinePoint = {
|
||||
time: string
|
||||
revolution: number
|
||||
lat: number
|
||||
long: number
|
||||
latLeft: number
|
||||
longLeft: number
|
||||
latInnerLeft: number
|
||||
longInnerLeft: number
|
||||
latInnerRight: number
|
||||
longInnerRight: number
|
||||
latRight: number
|
||||
longRight: number
|
||||
revSign: 'ASC' | 'DESC'
|
||||
}
|
||||
|
||||
/**
|
||||
* Трасса полёта КА (flight-line) на интервале для отрисовки на карте/таймлайне.
|
||||
* @param noradId NORAD-идентификатор КА.
|
||||
* @param fromMs Начало интервала, мс от эпохи.
|
||||
* @param toMs Конец интервала, мс от эпохи.
|
||||
*
|
||||
* @returns Точки трассы полёта на интервале.
|
||||
*/
|
||||
export async function fetchFlightLine(
|
||||
noradId: string,
|
||||
fromMs: number,
|
||||
toMs: number,
|
||||
): Promise<FlightLinePoint[]> {
|
||||
const query = new URLSearchParams({
|
||||
time_start: toLocalIso(fromMs),
|
||||
time_stop: toLocalIso(toMs),
|
||||
})
|
||||
return fetchJson<FlightLinePoint[]>(
|
||||
`/api/tgu-planning/spacecraft/${encodeURIComponent(noradId)}/flight-line?${query}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Список платформ (КА) ТГУ.
|
||||
* @returns Платформы планирования ТГУ.
|
||||
*/
|
||||
export async function fetchPlatforms(): Promise<TguPlatform[]> {
|
||||
return fetchJson<TguPlatform[]>('/api/tgu-planning/platforms')
|
||||
}
|
||||
|
||||
/**
|
||||
* Планы ТГУ с заданной глубиной истории, опционально по одному КА.
|
||||
* @param historyDays Глубина истории в сутках.
|
||||
* @param spacecraftId Идентификатор КА для фильтра (необязателен — иначе по всем КА).
|
||||
*
|
||||
* @returns Планы ТГУ.
|
||||
*/
|
||||
export async function fetchPlans(historyDays: number, spacecraftId?: string): Promise<TguPlan[]> {
|
||||
const query = new URLSearchParams({ historyDays: String(historyDays) })
|
||||
const path = spacecraftId
|
||||
? `/api/tgu-planning/spacecraft/${encodeURIComponent(spacecraftId)}/plans?${query}`
|
||||
: `/api/tgu-planning/plans?${query}`
|
||||
|
||||
return fetchJson<TguPlan[]>(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет решение оператора по плану (принять/отклонить и т.п.).
|
||||
* @param planId Идентификатор плана.
|
||||
* @param decision Решение по плану.
|
||||
* @param reason Причина решения (необязательна).
|
||||
*
|
||||
* @returns Сообщение-результат обработки решения.
|
||||
*/
|
||||
export async function sendPlanDecision(
|
||||
planId: string,
|
||||
decision: TguPlanDecision,
|
||||
reason?: string,
|
||||
): Promise<TguPlanDecisionMessage> {
|
||||
const query = new URLSearchParams({ decision })
|
||||
if (reason?.trim()) {
|
||||
query.set('reason', reason.trim())
|
||||
}
|
||||
|
||||
return fetchJson<TguPlanDecisionMessage>(
|
||||
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/decision?${query}`,
|
||||
{ method: 'POST' },
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить
|
||||
* индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый.
|
||||
* @param planId Идентификатор плана.
|
||||
*/
|
||||
export async function dismissPlan(planId: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* «Вернуть на учёт» ранее снятый план.
|
||||
* @param planId Идентификатор плана.
|
||||
*/
|
||||
export async function restorePlan(planId: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ.
|
||||
* decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»).
|
||||
* @param planId Идентификатор плана.
|
||||
*/
|
||||
export async function confirmPlanLayin(planId: string): Promise<void> {
|
||||
await fetchJson<void>(`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await apiFetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
...init,
|
||||
})
|
||||
} catch {
|
||||
throw new TguApiError('Не удалось подключиться к backend API.', 'network')
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
throw new TguApiError(
|
||||
errorMessage(response.status, text, decisionRequest),
|
||||
'http',
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
function errorMessage(status: number, body: string, decisionRequest: boolean): string {
|
||||
if (decisionRequest && status === 404) {
|
||||
return 'План не найден.'
|
||||
}
|
||||
if (decisionRequest && status === 409) {
|
||||
return 'План не является активным планом, ожидающим решения, или для него нет active attempt.'
|
||||
}
|
||||
if (decisionRequest && status >= 500) {
|
||||
return 'Ошибка сервиса при отправке решения.'
|
||||
}
|
||||
|
||||
return extractErrorText(body) || 'Ошибка backend API.'
|
||||
}
|
||||
|
||||
function extractErrorText(body: string): string {
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>
|
||||
for (const field of ['reason', 'message', 'error', 'detail']) {
|
||||
const value = parsed[field]
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
Reference in New Issue
Block a user