pcp-tgu-ops-ui: снятие плана с учёта + индикатор КА = нерешённый отказ

Индикатор «проблема КА» теперь означает НЕразрешённый отказ, а не «есть
хоть один REJECTED»: unresolvedProblemSpacecraftIds не красит КА, если
проблемный план снят с учёта (dismissedAt) либо цепочка уже продвинулась
за него (advanced-план со стартом не раньше). Исправленные и снятые
отказы гаснут сами.

- кнопка «Снять с учёта» / «Вернуть на учёт» в деталях плана для
  терминально-негативных статусов + бейдж «снят с учёта»;
- dismissPlan/restorePlan (через BFF), поле dismissedAt в модели;
- снятый план приглушён и штрихуется на таймлайне;
- тесты на unresolvedProblemSpacecraftIds.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 16:59:28 +03:00
parent e6f1219c9c
commit 9f1756a9f9
8 changed files with 227 additions and 16 deletions
+19
View File
@@ -67,6 +67,25 @@ export async function sendPlanDecision(
); );
} }
/**
* «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить
* индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый.
*/
export async function dismissPlan(planId: string): Promise<void> {
await fetchJson<void>(
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`,
{ method: "POST" }
);
}
/** «Вернуть на учёт» ранее снятый план. */
export async function restorePlan(planId: string): Promise<void> {
await fetchJson<void>(
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`,
{ method: "POST" }
);
}
/** /**
* ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ. * ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ.
* decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»). * decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»).
@@ -11,6 +11,9 @@ import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
// config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes. // config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes.
const DECISION_GRACE_MINUTES = 30; const DECISION_GRACE_MINUTES = 30;
/** Терминально-негативные статусы, которые можно «снять с учёта» (синхронно с бэком DismissPlanUseCase). */
const DISMISSIBLE_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS", "LAYIN_FAILED", "NOT_ACCEPTED", "SUPERSEDED"]);
/** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */ /** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */
export type PlanModeCounts = { export type PlanModeCounts = {
survey: number; survey: number;
@@ -38,6 +41,10 @@ type TguPlanDetailsProps = {
onConfirmLayin?: (planId: string) => void; onConfirmLayin?: (planId: string) => void;
/** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */ /** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */
layinConflict?: boolean; layinConflict?: boolean;
/** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */
onDismissPlan?: (planId: string) => void;
/** «Вернуть на учёт» ранее снятый план. */
onRestorePlan?: (planId: string) => void;
onClose: () => void; onClose: () => void;
}; };
@@ -56,6 +63,8 @@ export function TguPlanDetails({
onCreateDraftFromPlan, onCreateDraftFromPlan,
onConfirmLayin, onConfirmLayin,
layinConflict, layinConflict,
onDismissPlan,
onRestorePlan,
onClose onClose
}: TguPlanDetailsProps) { }: TguPlanDetailsProps) {
if (!plan) { if (!plan) {
@@ -100,6 +109,13 @@ export function TguPlanDetails({
onConfirmLayin?.(plan.planId); onConfirmLayin?.(plan.planId);
}; };
const dismissed = Boolean(plan.dismissedAt);
const canDismiss = DISMISSIBLE_STATUSES.has(plan.status);
const dismiss = () => {
if (!window.confirm("Снять план с учёта? Он перестанет считаться проблемой и не будет красить КА. Статус и цепочку это не меняет; можно вернуть на учёт.")) return;
onDismissPlan?.(plan.planId);
};
return ( return (
<aside className="tgu-details"> <aside className="tgu-details">
<div className="tgu-details__header"> <div className="tgu-details__header">
@@ -112,7 +128,10 @@ export function TguPlanDetails({
</button> </button>
</div> </div>
<div className="tgu-details__pills">
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div> <div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
{dismissed && <div className="tgu-status-pill tgu-status-pill--dismissed">снят с учёта</div>}
</div>
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>} {notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>} {error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
@@ -230,6 +249,36 @@ export function TguPlanDetails({
)} )}
</div> </div>
)} )}
{canDismiss && (
dismissed
? onRestorePlan && (
<div className="tgu-details__actions">
<button
className="tgu-button"
type="button"
disabled={draftActionInFlight}
title="Вернуть план на учёт — снова будет считаться проблемой КА"
onClick={() => onRestorePlan(plan.planId)}
>
Вернуть на учёт
</button>
</div>
)
: onDismissPlan && (
<div className="tgu-details__actions">
<button
className="tgu-button"
type="button"
disabled={draftActionInFlight}
title="Снять отменённый/неудавшийся план с учёта, чтобы он не считался проблемой и не красил КА"
onClick={dismiss}
>
Снять с учёта
</button>
</div>
)
)}
</aside> </aside>
); );
} }
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { confirmPlanLayin, fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi"; import {
confirmPlanLayin,
dismissPlan,
fetchPlans,
fetchPlatforms,
restorePlan,
sendPlanDecision
} from "../../api/tguApi";
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes"; import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguEditorTab } from "../tgu-editor/TguEditorTab"; import { TguEditorTab } from "../tgu-editor/TguEditorTab";
@@ -389,6 +396,37 @@ export function TguPlanningPage() {
} }
}; };
// Снять/вернуть план с учёта: косметико-статистический флаг, не трогает статус и цепочку.
const handleDismissPlan = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await dismissPlan(planId);
setDecisionNotice("План снят с учёта — больше не считается проблемой КА.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось снять план с учёта.");
} finally {
setDraftActionInFlight(false);
}
};
const handleRestorePlan = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await restorePlan(planId);
setDecisionNotice("План возвращён на учёт.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось вернуть план на учёт.");
} finally {
setDraftActionInFlight(false);
}
};
return ( return (
<TguPlanningLayout <TguPlanningLayout
toolbar={ toolbar={
@@ -489,6 +527,8 @@ export function TguPlanningPage() {
onCreateDraftFromPlan={handleCreateDraftFromPlan} onCreateDraftFromPlan={handleCreateDraftFromPlan}
onConfirmLayin={handleConfirmLayin} onConfirmLayin={handleConfirmLayin}
layinConflict={layinConflict} layinConflict={layinConflict}
onDismissPlan={handleDismissPlan}
onRestorePlan={handleRestorePlan}
onClose={() => setSelectedPlanId(undefined)} onClose={() => setSelectedPlanId(undefined)}
/> />
)} )}
@@ -237,15 +237,20 @@ export function TguTimeline({
// При активном выборе все «непричастные» планы гасим до еле заметных, а текущий / // При активном выборе все «непричастные» планы гасим до еле заметных, а текущий /
// прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана. // прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана.
const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined; const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined;
// Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение).
const dismissed = Boolean(segment.plan.dismissedAt);
return ( return (
<g <g
key={segment.plan.planId} key={segment.plan.planId}
className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""}`} className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""} ${
dismissed ? "is-dismissed-plan" : ""
}`}
onClick={() => onSelectPlan(segment.plan.planId)} onClick={() => onSelectPlan(segment.plan.planId)}
> >
<title> <title>
{segment.plan.planId} · {segment.plan.status} {segment.plan.planId} · {segment.plan.status}
{dismissed ? " · снят с учёта" : ""}
{neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "} {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "}
{formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)} {formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)}
</title> </title>
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes"; import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes"; import type { TguPlanUi } from "../../model/timelineTypes";
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper"; import {
isOperationalPlatform,
operationalSpacecraftIds,
pausedSpacecraftIds,
unresolvedProblemSpacecraftIds
} from "./tguTimelineMapper";
import { toLocalIso } from "../../utils/localTime"; import { toLocalIso } from "../../utils/localTime";
function platform(overrides: Partial<TguPlatform>): TguPlatform { function platform(overrides: Partial<TguPlatform>): TguPlatform {
@@ -78,3 +83,44 @@ describe("pausedSpacecraftIds", () => {
expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set()); expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set());
}); });
}); });
describe("unresolvedProblemSpacecraftIds (красный индикатор КА)", () => {
const t0 = Date.UTC(2026, 5, 4, 0, 0, 0);
const hour = 3_600_000;
function dismissed(plan: TguPlanUi): TguPlanUi {
return { ...plan, dismissedAt: toLocalIso(t0) };
}
it("красит КА, если есть нерешённый REJECTED", () => {
expect(unresolvedProblemSpacecraftIds([uiPlan("100", "REJECTED", t0)])).toEqual(new Set(["100"]));
});
it("красит КА на START_AMBIGUOUS", () => {
expect(unresolvedProblemSpacecraftIds([uiPlan("100", "START_AMBIGUOUS", t0)])).toEqual(new Set(["100"]));
});
it("НЕ красит, если проблемный план снят с учёта", () => {
expect(unresolvedProblemSpacecraftIds([dismissed(uiPlan("100", "REJECTED", t0))])).toEqual(new Set());
});
it("НЕ красит, если цепочка продвинулась за отказ (исправленный план в полёте)", () => {
const ids = unresolvedProblemSpacecraftIds([
uiPlan("100", "REJECTED", t0),
uiPlan("100", "WAITING_DECISION", t0 + hour)
]);
expect(ids.has("100")).toBe(false);
});
it("красит, если продвинувший план раньше отказа (отказ всё ещё актуален)", () => {
const ids = unresolvedProblemSpacecraftIds([
uiPlan("100", "REJECTED", t0 + 5 * hour),
uiPlan("100", "AWAITING_LAYIN", t0)
]);
expect(ids).toEqual(new Set(["100"]));
});
it("успешные/живые статусы сами по себе не красят", () => {
expect(unresolvedProblemSpacecraftIds([uiPlan("100", "LAID_IN", t0)])).toEqual(new Set());
});
});
@@ -47,28 +47,28 @@ export function buildPlatformsForInterval(
): TguPlatformUi[] { ): TguPlatformUi[] {
const intervalPlans = filterPlansByRange(plans, range); const intervalPlans = filterPlansByRange(plans, range);
const platformByNorad = buildPlatformByNorad(platforms); const platformByNorad = buildPlatformByNorad(platforms);
const planStats = new Map<string, { count: number; hasProblemStatus: boolean }>();
// Красный индикатор КА = есть НЕразрешённый проблемный план (см. unresolvedProblemSpacecraftIds):
// отказ, который оператор ещё не обработал (цепочка не продвинулась) и не снял с учёта. Прежняя
// логика «есть хоть один REJECTED» красила КА вечно — даже после исправления или снятия с учёта.
const problemSpacecraft = unresolvedProblemSpacecraftIds(intervalPlans);
const planCount = new Map<string, number>();
for (const plan of intervalPlans) { for (const plan of intervalPlans) {
const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false }; planCount.set(plan.spacecraftId, (planCount.get(plan.spacecraftId) ?? 0) + 1);
stats.count += 1;
stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status);
planStats.set(plan.spacecraftId, stats);
} }
const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => { const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) =>
const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false }; toPlatformUi(spacecraftId, platform, planCount.get(spacecraftId) ?? 0, problemSpacecraft.has(spacecraftId))
return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus); );
});
const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId)); const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
for (const [spacecraftId, stats] of planStats.entries()) { for (const [spacecraftId, count] of planCount.entries()) {
if (!knownSpacecraft.has(spacecraftId)) { if (!knownSpacecraft.has(spacecraftId)) {
platformItems.push({ platformItems.push({
spacecraftId, spacecraftId,
noradId: spacecraftId, noradId: spacecraftId,
planCount: stats.count, planCount: count,
hasProblemStatus: stats.hasProblemStatus hasProblemStatus: problemSpacecraft.has(spacecraftId)
}); });
} }
} }
@@ -95,6 +95,32 @@ const CHAIN_ADVANCED_STATUSES = new Set([
* отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) — * отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) —
* оператор должен вручную создать исправленный план. * оператор должен вручную создать исправленный план.
*/ */
/**
* КА с НЕразрешённой проблемой — для красного индикатора. Проблемный план (REJECTED/START_AMBIGUOUS)
* считается разрешённым и НЕ красит КА, если он либо снят с учёта (`dismissedAt`), либо цепочка уже
* продвинулась за него (есть [CHAIN_ADVANCED_STATUSES]-план со стартом не раньше). Так исправленные
* и снятые с учёта отказы гаснут сами, а реальные нерешённые — остаются красными.
*/
export function unresolvedProblemSpacecraftIds(plans: TguPlanUi[]): Set<string> {
const latestAdvancedStart = new Map<string, number>();
for (const plan of plans) {
if (CHAIN_ADVANCED_STATUSES.has(plan.status)) {
const prev = latestAdvancedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY;
if (plan.startMs > prev) latestAdvancedStart.set(plan.spacecraftId, plan.startMs);
}
}
const unresolved = new Set<string>();
for (const plan of plans) {
if (!PROBLEM_STATUSES.has(plan.status)) continue;
if (plan.dismissedAt) continue; // снят с учёта оператором
const advancedStart = latestAdvancedStart.get(plan.spacecraftId);
if (advancedStart !== undefined && advancedStart >= plan.startMs) continue; // цепочка ушла дальше
unresolved.add(plan.spacecraftId);
}
return unresolved;
}
export function pausedSpacecraftIds(plans: TguPlanUi[]): Set<string> { export function pausedSpacecraftIds(plans: TguPlanUi[]): Set<string> {
const latestRejectedStart = new Map<string, number>(); const latestRejectedStart = new Map<string, number>();
for (const plan of plans) { for (const plan of plans) {
@@ -23,6 +23,11 @@ export type TguPlan = {
basePlanId?: string | null; basePlanId?: string | null;
/** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */ /** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */
projected?: boolean; projected?: boolean;
/**
* Время «снятия с учёта» (ISO без зоны) терминально-негативного плана. `null`/отсутствует — план
* на учёте. Снятый план гасится на таймлайне и не считается проблемой в индикаторе КА.
*/
dismissedAt?: string | null;
}; };
export type TguPlatform = { export type TguPlatform = {
@@ -440,6 +440,15 @@
transition: opacity 0.12s ease; transition: opacity 0.12s ease;
} }
/* Снят с учёта: приглушаем план и делаем штриховку — виден, но явно «второстепенный».
Объявлен ВЫШЕ is-dimmed, чтобы при активном выборе сильное выбор-затемнение перебивало это. */
.tgu-timeline-plan.is-dismissed-plan {
opacity: 0.4;
}
.tgu-timeline-plan.is-dismissed-plan .tgu-timeline-plan__bar {
stroke-dasharray: 3 4;
}
/* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются /* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются
яркие рамки текущего/прошлого/следующего звена цепочки. */ яркие рамки текущего/прошлого/следующего звена цепочки. */
.tgu-timeline-plan.is-dimmed { .tgu-timeline-plan.is-dimmed {
@@ -556,6 +565,18 @@
font-weight: 800; font-weight: 800;
} }
.tgu-details__pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
/* Бейдж «снят с учёта» рядом со статусом: нейтральный серый, чтобы не путать со статусом. */
.tgu-status-pill--dismissed {
background: #475569;
color: #e2e8f0;
}
.tgu-details-grid { .tgu-details-grid {
display: grid; display: grid;
gap: 0.72rem; gap: 0.72rem;