pcp-tgu интерфейс

This commit is contained in:
Дмитрий Соловьев
2026-05-29 16:09:36 +03:00
parent 913b3d7c9b
commit 9fca4d4051
34 changed files with 3163 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Планирование ТГУ Ops UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://host.docker.internal:7008/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { TguPlanningPage } from "./features/tgu-planning/TguPlanningPage";
export default function App() {
return <TguPlanningPage />;
}
+93
View File
@@ -0,0 +1,93 @@
import {
TguApiError,
type TguPlan,
type TguPlanDecision,
type TguPlanDecisionMessage,
type TguPlatform
} from "../model/tguTypes";
export async function fetchPlatforms(): Promise<TguPlatform[]> {
return fetchJson<TguPlatform[]>("/api/tgu-planning/platforms");
}
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);
}
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
);
}
async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> {
let response: Response;
try {
response = await fetch(url, {
headers: { Accept: "application/json" },
...init
});
} catch (error) {
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;
}
@@ -0,0 +1,15 @@
import { STATUS_STYLES } from "./tguStatus";
export function TguLegend() {
return (
<div className="tgu-legend" aria-label="Легенда статусов">
{STATUS_STYLES.map((item) => (
<span className="tgu-legend__item" key={item.label}>
<span className={`tgu-legend__swatch ${item.className}`} />
{item.label}
</span>
))}
<span className="tgu-legend__note">Стрелки показывают следующий план того же КА по времени.</span>
</div>
);
}
@@ -0,0 +1,102 @@
import type { TguPlanDecision } from "../../model/tguTypes";
import type { TguPlanUi } from "../../model/timelineTypes";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
type TguPlanDetailsProps = {
plan?: TguPlanUi;
decisionInFlight: boolean;
notice?: string;
error?: string;
onDecision: (planId: string, decision: TguPlanDecision, reason?: string) => void;
onClose: () => void;
};
export function TguPlanDetails({ plan, decisionInFlight, notice, error, onDecision, onClose }: TguPlanDetailsProps) {
if (!plan) {
return (
<aside className="tgu-details tgu-details--empty">
<div className="tgu-details__placeholder">
<span className="tgu-details__placeholder-icon" aria-hidden="true" />
<span>Выберите plan bar на timeline.</span>
</div>
</aside>
);
}
const style = statusStyle(plan.status);
const duration = formatDuration(plan.endMs - plan.startMs);
const reject = () => {
const reason = window.prompt("Reason для REJECTED", "UI_TEST_REJECTED");
if (reason === null) return;
onDecision(plan.planId, "REJECTED", reason);
};
return (
<aside className="tgu-details">
<div className="tgu-details__header">
<div>
<div className="tgu-details__eyebrow">Выбранный план</div>
<h2>{plan.planId}</h2>
</div>
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть детали">
x
</button>
</div>
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
<dl className="tgu-details-grid">
<Detail label="planId" value={plan.planId} mono />
<Detail label="spacecraftId" value={plan.spacecraftId} mono />
<Detail label="КА" value={platformLabel(plan.platform, plan.spacecraftId)} />
<Detail label="startTime" value={formatDateTime(plan.startTime)} mono />
<Detail label="endTime" value={formatDateTime(plan.endTime)} mono />
<Detail label="duration" value={duration} mono />
<Detail label="kppId" value={plan.kppId} mono />
<Detail label="status" value={plan.status} mono />
</dl>
{plan.status === "WAITING_DECISION" && (
<div className="tgu-details__actions">
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight}
onClick={() => onDecision(plan.planId, "ACCEPTED", "UI_TEST_ACCEPTED")}
>
Принять
</button>
<button className="tgu-button tgu-button--reject" type="button" disabled={decisionInFlight} onClick={reject}>
Отклонить
</button>
</div>
)}
</aside>
);
}
function Detail({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
return (
<div>
<dt>{label}</dt>
<dd className={mono ? "mono" : undefined}>{value || "-"}</dd>
</div>
);
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
function formatDuration(ms: number): string {
const minutes = Math.max(0, Math.round(ms / 60_000));
const hours = Math.floor(minutes / 60);
const restMinutes = minutes % 60;
if (hours === 0) return `${restMinutes} мин`;
return `${hours} ч ${restMinutes} мин`;
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
type TguPlanningLayoutProps = {
toolbar: ReactNode;
sidebar: ReactNode;
timeline: ReactNode;
details: ReactNode;
};
export function TguPlanningLayout({ toolbar, sidebar, timeline, details }: TguPlanningLayoutProps) {
return (
<div className="tgu-app-shell">
{toolbar}
<main className="tgu-workspace">
{sidebar}
<section className="tgu-center">{timeline}</section>
{details}
</main>
</div>
);
}
@@ -0,0 +1,217 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
import { TguTimeline } from "./TguTimeline";
import { TguToolbar } from "./TguToolbar";
import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper";
const DAY_MS = 24 * 60 * 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
const [toValue, setToValue] = useState(formatDateTimeLocal(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
const [plans, setPlans] = useState<TguPlanUi[]>([]);
const [loading, setLoading] = useState(false);
const [pageError, setPageError] = useState<string>();
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
const parsedInputRange = useMemo(
() => ({
fromMs: new Date(fromValue).getTime(),
toMs: new Date(toValue).getTime()
}),
[fromValue, toValue]
);
const invalidRange =
!Number.isFinite(parsedInputRange.fromMs) ||
!Number.isFinite(parsedInputRange.toMs) ||
parsedInputRange.fromMs >= parsedInputRange.toMs;
const loadData = useCallback(
async (range: TimelineRange) => {
setLoading(true);
setPageError(undefined);
setDecisionError(undefined);
const historyDays = historyDaysForRange(range);
const [platformResult, planResult] = await Promise.allSettled([
fetchPlatforms(),
fetchPlans(historyDays)
]);
if (platformResult.status === "rejected") {
setPlatformLoadFailed(true);
} else {
setPlatformLoadFailed(false);
setRawPlatforms(platformResult.value);
}
if (planResult.status === "rejected") {
setPlans([]);
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
} else {
const platforms = platformResult.status === "fulfilled" ? platformResult.value : [];
setPlans(mapPlans(planResult.value, platforms));
}
setLoading(false);
},
[]
);
useEffect(() => {
void loadData(appliedRange);
}, [appliedRange, loadData]);
const platforms = useMemo(
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
[rawPlatforms, plans, appliedRange]
);
const rows = useMemo(
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
[plans, platforms, appliedRange, selectedSpacecraftId]
);
const selectedPlan = useMemo(() => {
const plan = plans.find((item) => item.planId === selectedPlanId);
if (!plan) return undefined;
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
return { ...plan, platform };
}, [plans, platforms, selectedPlanId]);
const applyRange = () => {
setDecisionNotice(undefined);
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
setPageError(undefined);
setAppliedRange(parsedInputRange);
};
const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000;
setFromValue(formatDateTimeLocal(fromMs));
setToValue(formatDateTimeLocal(toMs));
setAppliedRange({ fromMs, toMs });
setPageError(undefined);
setDecisionNotice(undefined);
};
const refresh = () => {
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
void loadData(appliedRange);
};
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
setDecisionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await sendPlanDecision(planId, decision, reason);
setDecisionNotice(
decision === "REJECTED"
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
: "Решение ACCEPTED отправлено."
);
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
} finally {
setDecisionInFlight(false);
}
};
return (
<TguPlanningLayout
toolbar={
<TguToolbar
fromValue={fromValue}
toValue={toValue}
loading={loading}
error={pageError}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
onQuickRange={quickRange}
onRefresh={refresh}
/>
}
sidebar={
<TguSidebar
platforms={platforms}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
}
timeline={
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={setSelectedPlanId}
/>
}
details={
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
}
/>
);
}
function defaultRange(): TimelineRange {
const fromMs = startOfCurrentDayMs();
return { fromMs, toMs: fromMs + DAY_MS };
}
function startOfCurrentDayMs(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function formatDateTimeLocal(ms: number): string {
const date = new Date(ms);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function historyDaysForRange(range: TimelineRange): number {
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
return Math.max(1, daysBack + 1);
}
@@ -0,0 +1,74 @@
import type { TguPlatformUi } from "../../model/timelineTypes";
import { filterPlatforms, platformLabel } from "./tguTimelineMapper";
type TguSidebarProps = {
platforms: TguPlatformUi[];
selectedSpacecraftId?: string;
search: string;
platformLoadFailed: boolean;
onSearchChange: (value: string) => void;
onSelectSpacecraft: (spacecraftId?: string) => void;
};
export function TguSidebar({
platforms,
selectedSpacecraftId,
search,
platformLoadFailed,
onSearchChange,
onSelectSpacecraft
}: TguSidebarProps) {
const filtered = filterPlatforms(platforms, search);
return (
<aside className="tgu-sidebar">
<div className="tgu-panel-heading">
<span>Космические аппараты</span>
<button className="tgu-link-button" type="button" onClick={() => onSelectSpacecraft(undefined)}>
Все
</button>
</div>
<div className="tgu-search">
<input
type="search"
placeholder="Поиск name / NORAD / mission"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/>
</div>
{platformLoadFailed && (
<div className="tgu-alert tgu-alert--warning">
Список платформ не загрузился. Timeline построен по spacecraftId из планов.
</div>
)}
<div className="tgu-spacecraft-list">
{filtered.map((platform) => {
const selected = selectedSpacecraftId === platform.spacecraftId;
return (
<button
className={`tgu-spacecraft ${selected ? "is-selected" : ""}`}
type="button"
key={platform.spacecraftId}
onClick={() => onSelectSpacecraft(platform.spacecraftId)}
>
<span className={`tgu-spacecraft__problem ${platform.hasProblemStatus ? "is-problem" : ""}`} />
<span className="tgu-spacecraft__body">
<span className="tgu-spacecraft__name">{platformLabel(platform, platform.spacecraftId)}</span>
<span className="tgu-spacecraft__meta">
NORAD {platform.noradId || platform.spacecraftId}
{platform.mission ? ` · ${platform.mission}` : ""}
</span>
{platform.status && <span className="tgu-spacecraft__status">{platform.status}</span>}
</span>
<span className="tgu-spacecraft__count">{platform.planCount}</span>
</button>
);
})}
{filtered.length === 0 && <div className="tgu-empty tgu-empty--compact">Нет КА по текущему поиску.</div>}
</div>
</aside>
);
}
@@ -0,0 +1,201 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
import {
buildSequentialLinks,
buildTimeTicks,
buildTimelineSegments,
TIMELINE_AXIS_HEIGHT,
TIMELINE_BOTTOM_PADDING,
TIMELINE_LABEL_WIDTH,
TIMELINE_ROW_HEIGHT,
timeToX,
timelineHeight
} from "./tguTimelineLayout";
import { platformLabel } from "./tguTimelineMapper";
import { statusStyle } from "./tguStatus";
type TguTimelineProps = {
rows: TimelineRow[];
range: TimelineRange;
selectedPlanId?: string;
invalidRange?: boolean;
onSelectPlan: (planId: string) => void;
};
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(1200);
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
const fullWidth = TIMELINE_LABEL_WIDTH + timelineWidth;
const height = Math.max(260, timelineHeight(rows.length));
const nowMs = Date.now();
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(Math.floor(entry.contentRect.width));
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
const segments = useMemo(
() => buildTimelineSegments(rows, range, timelineWidth),
[rows, range, timelineWidth]
);
const segmentByPlanId = useMemo(
() => new Map(segments.map((segment) => [segment.plan.planId, segment])),
[segments]
);
const links = useMemo(() => buildSequentialLinks(rows), [rows]);
const ticks = useMemo(() => buildTimeTicks(range), [range]);
const nowX =
nowMs >= range.fromMs && nowMs <= range.toMs
? TIMELINE_LABEL_WIDTH + timeToX(nowMs, range, timelineWidth)
: undefined;
if (invalidRange) {
return <div className="tgu-empty">Некорректный интервал: from должен быть меньше to.</div>;
}
if (rows.length === 0 || segments.length === 0) {
return <div className="tgu-empty">Нет планов в выбранном интервале.</div>;
}
return (
<div className="tgu-timeline" ref={containerRef}>
<svg className="tgu-timeline__svg" viewBox={`0 0 ${fullWidth} ${height}`} role="img" aria-label="Timeline планов ТГУ">
<defs>
<marker id="tgu-arrow-head" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<path d="M0 0 L8 4 L0 8 z" fill="#8aa2a6" />
</marker>
</defs>
<rect x="0" y="0" width={fullWidth} height={height} className="tgu-timeline__background" />
<rect x="0" y="0" width={TIMELINE_LABEL_WIDTH} height={height} className="tgu-timeline__labels-background" />
<line
x1={TIMELINE_LABEL_WIDTH}
x2={fullWidth}
y1={TIMELINE_AXIS_HEIGHT}
y2={TIMELINE_AXIS_HEIGHT}
className="tgu-timeline__axis"
/>
{ticks.map((tick) => {
const x = TIMELINE_LABEL_WIDTH + timeToX(tick, range, timelineWidth);
return (
<g key={tick}>
<line x1={x} x2={x} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__grid" />
<text x={x + 6} y="18" className="tgu-timeline__tick-date">
{formatTickDate(tick)}
</text>
<text x={x + 6} y="34" className="tgu-timeline__tick-time">
{formatTickTime(tick)}
</text>
</g>
);
})}
{nowX !== undefined && (
<g>
<line x1={nowX} x2={nowX} y1="0" y2={height - TIMELINE_BOTTOM_PADDING} className="tgu-timeline__now" />
<text x={nowX + 7} y="16" className="tgu-timeline__now-label">
сейчас
</text>
</g>
)}
{rows.map((row, rowIndex) => {
const y = TIMELINE_AXIS_HEIGHT + rowIndex * TIMELINE_ROW_HEIGHT;
return (
<g key={row.spacecraftId}>
<rect
x="0"
y={y}
width={fullWidth}
height={TIMELINE_ROW_HEIGHT}
className={rowIndex % 2 === 0 ? "tgu-timeline__row" : "tgu-timeline__row tgu-timeline__row--alt"}
/>
<text x="16" y={y + 24} className="tgu-timeline__row-title">
{platformLabel(row.platform, row.spacecraftId)}
</text>
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
NORAD {row.platform?.noradId || row.spacecraftId}
</text>
</g>
);
})}
{links.map((link) => {
const from = segmentByPlanId.get(link.fromPlanId);
const to = segmentByPlanId.get(link.toPlanId);
if (!from || !to) return null;
return <TimelineArrow key={`${link.fromPlanId}-${link.toPlanId}`} from={from} to={to} />;
})}
{segments.map((segment) => {
const y = TIMELINE_AXIS_HEIGHT + segment.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const x1 = TIMELINE_LABEL_WIDTH + segment.x;
const x2 = TIMELINE_LABEL_WIDTH + segment.x + segment.width;
const style = statusStyle(segment.plan.status);
const selected = selectedPlanId === segment.plan.planId;
const labelVisible = segment.width > 92;
return (
<g
key={segment.plan.planId}
className={`tgu-timeline-plan ${selected ? "is-selected" : ""}`}
onClick={() => onSelectPlan(segment.plan.planId)}
>
<title>
{segment.plan.planId} · {segment.plan.status} · {formatDateTime(segment.plan.startTime)} -{" "}
{formatDateTime(segment.plan.endTime)}
</title>
{selected && <line x1={x1} x2={x2} y1={y} y2={y} className="tgu-timeline-plan__selection" />}
<line
x1={x1}
x2={x2}
y1={y}
y2={y}
className={`tgu-timeline-plan__bar ${style.className}`}
stroke={style.color}
/>
{labelVisible && (
<text x={x1 + 10} y={y + 4} className="tgu-timeline-plan__label">
{segment.plan.kppId} · {segment.plan.status}
</text>
)}
</g>
);
})}
</svg>
</div>
);
}
function TimelineArrow({ from, to }: { from: TimelinePlanSegment; to: TimelinePlanSegment }) {
const y1 = TIMELINE_AXIS_HEIGHT + from.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const y2 = TIMELINE_AXIS_HEIGHT + to.rowIndex * TIMELINE_ROW_HEIGHT + TIMELINE_ROW_HEIGHT / 2;
const x1 = TIMELINE_LABEL_WIDTH + from.x + from.width + 4;
const x2 = TIMELINE_LABEL_WIDTH + to.x - 6;
const mid = x1 + Math.max(18, (x2 - x1) / 2);
const path = x2 > x1
? `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2} ${y2}`
: `M ${x1} ${y1} C ${x1 + 20} ${y1 - 18}, ${x2 - 20} ${y2 - 18}, ${x2} ${y2}`;
return <path d={path} className="tgu-timeline__arrow" markerEnd="url(#tgu-arrow-head)" />;
}
function formatTickDate(ms: number): string {
return new Date(ms).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
function formatTickTime(ms: number): string {
return new Date(ms).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU");
}
@@ -0,0 +1,66 @@
import { TguLegend } from "./TguLegend";
type TguToolbarProps = {
fromValue: string;
toValue: string;
loading: boolean;
error?: string;
onFromChange: (value: string) => void;
onToChange: (value: string) => void;
onApply: () => void;
onQuickRange: (hours: number) => void;
onRefresh: () => void;
};
export function TguToolbar({
fromValue,
toValue,
loading,
error,
onFromChange,
onToChange,
onApply,
onQuickRange,
onRefresh
}: TguToolbarProps) {
return (
<header className="tgu-toolbar">
<div className="tgu-toolbar__row">
<div className="tgu-toolbar__brand">
<span className="tgu-toolbar__mark" aria-hidden="true" />
<div>
<div className="tgu-toolbar__title">Планирование ТГУ</div>
<div className="tgu-toolbar__subtitle">Mission Ops</div>
</div>
</div>
<label className="tgu-field">
<span>С</span>
<input type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
</label>
<label className="tgu-field">
<span>По</span>
<input type="datetime-local" value={toValue} onChange={(event) => onToChange(event.target.value)} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
Показать
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(24)} disabled={loading}>
24ч
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(72)} disabled={loading}>
3 суток
</button>
<button className="tgu-button" type="button" onClick={() => onQuickRange(168)} disabled={loading}>
7 суток
</button>
<button className="tgu-button" type="button" onClick={onRefresh} disabled={loading}>
{loading ? "Обновление..." : "Обновить"}
</button>
</div>
<TguLegend />
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</header>
);
}
@@ -0,0 +1,26 @@
import type { TguPlanStatus } from "../../model/tguTypes";
import type { TimelineStatusStyle } from "../../model/timelineTypes";
export const STATUS_STYLES: TimelineStatusStyle[] = [
{ status: "ACCEPTED", label: "ACCEPTED", color: "#2f9e44", className: "status-accepted" },
{ status: "REJECTED", label: "REJECTED", color: "#d64545", className: "status-rejected" },
{ status: "WAITING_DECISION", label: "WAITING_DECISION", color: "#f0ad2e", className: "status-waiting" },
{ status: "PLANNED", label: "PLANNED", color: "#8d99a6", className: "status-planned" },
{ status: "ISSUING", label: "ISSUING", color: "#2f80ed", className: "status-issuing" },
{ status: "EXPIRED", label: "EXPIRED", color: "#495057", className: "status-expired" },
{ status: "SUPERSEDED", label: "SUPERSEDED", color: "#c7ced6", className: "status-superseded" },
{ status: "START_AMBIGUOUS", label: "START_AMBIGUOUS", color: "#b42318", className: "status-ambiguous" }
];
const STATUS_BY_NAME = new Map(STATUS_STYLES.map((style) => [style.status, style]));
export function statusStyle(status: TguPlanStatus): TimelineStatusStyle {
return (
STATUS_BY_NAME.get(status) ?? {
status,
label: status || "UNKNOWN",
color: "#79828d",
className: "status-unknown"
}
);
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import type { TguPlanUi, TimelineRow } from "../../model/timelineTypes";
import { buildSequentialLinks, clipPlanToRange, timeToX } from "./tguTimelineLayout";
const range = {
fromMs: Date.parse("2026-05-29T00:00:00"),
toMs: Date.parse("2026-05-30T00:00:00")
};
function plan(id: string, startTime: string, endTime: string, spacecraftId = "56756"): TguPlanUi {
return {
planId: id,
spacecraftId,
startTime,
endTime,
kppId: "KPP-1",
status: "PLANNED",
startMs: Date.parse(startTime),
endMs: Date.parse(endTime)
};
}
describe("tguTimelineLayout", () => {
it("maps time to x inside the selected interval", () => {
expect(timeToX(Date.parse("2026-05-29T12:00:00"), range, 1200)).toBe(600);
});
it("clips partially visible plans to the selected interval", () => {
const clipped = clipPlanToRange(plan("p1", "2026-05-28T23:00:00", "2026-05-29T02:00:00"), range);
expect(clipped).toEqual({
startMs: range.fromMs,
endMs: Date.parse("2026-05-29T02:00:00")
});
});
it("builds links only between sequential plans of the same spacecraft", () => {
const rows: TimelineRow[] = [
{
spacecraftId: "56756",
plans: [
plan("p2", "2026-05-29T04:00:00", "2026-05-29T05:00:00"),
plan("p1", "2026-05-29T01:00:00", "2026-05-29T02:00:00")
]
},
{
spacecraftId: "777",
plans: [
plan("p3", "2026-05-29T01:00:00", "2026-05-29T02:00:00", "777")
]
}
];
expect(buildSequentialLinks(rows)).toEqual([
{ spacecraftId: "56756", fromPlanId: "p1", toPlanId: "p2" }
]);
});
});
@@ -0,0 +1,93 @@
import type { TguPlanUi, TimelineLink, TimelinePlanSegment, TimelineRange, TimelineRow } from "../../model/timelineTypes";
export const TIMELINE_LABEL_WIDTH = 220;
export const TIMELINE_ROW_HEIGHT = 58;
export const TIMELINE_AXIS_HEIGHT = 46;
export const TIMELINE_BOTTOM_PADDING = 18;
export function timeToX(timeMs: number, range: TimelineRange, timelineWidth: number): number {
return ((timeMs - range.fromMs) / (range.toMs - range.fromMs)) * timelineWidth;
}
export function clipPlanToRange(plan: TguPlanUi, range: TimelineRange): { startMs: number; endMs: number } | null {
if (plan.endMs < range.fromMs || plan.startMs > range.toMs) {
return null;
}
return {
startMs: Math.max(plan.startMs, range.fromMs),
endMs: Math.min(plan.endMs, range.toMs)
};
}
export function buildTimelineSegments(
rows: TimelineRow[],
range: TimelineRange,
timelineWidth: number
): TimelinePlanSegment[] {
const segments: TimelinePlanSegment[] = [];
rows.forEach((row, rowIndex) => {
for (const plan of row.plans) {
const clipped = clipPlanToRange(plan, range);
if (!clipped) continue;
const x = timeToX(clipped.startMs, range, timelineWidth);
const x2 = timeToX(clipped.endMs, range, timelineWidth);
segments.push({
plan,
rowIndex,
x,
x2,
width: Math.max(3, x2 - x)
});
}
});
return segments;
}
export function buildSequentialLinks(rows: TimelineRow[]): TimelineLink[] {
const links: TimelineLink[] = [];
for (const row of rows) {
const sorted = [...row.plans].sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
for (let index = 0; index < sorted.length - 1; index += 1) {
links.push({
spacecraftId: row.spacecraftId,
fromPlanId: sorted[index].planId,
toPlanId: sorted[index + 1].planId
});
}
}
return links;
}
export function buildTimeTicks(range: TimelineRange, targetCount = 8): number[] {
const span = range.toMs - range.fromMs;
const roughStep = span / targetCount;
const candidates = [
15 * 60_000,
30 * 60_000,
60 * 60_000,
2 * 60 * 60_000,
3 * 60 * 60_000,
6 * 60 * 60_000,
12 * 60 * 60_000,
24 * 60 * 60_000
];
const step = candidates.find((candidate) => candidate >= roughStep) ?? 24 * 60 * 60_000;
const first = Math.ceil(range.fromMs / step) * step;
const ticks: number[] = [];
for (let tick = first; tick <= range.toMs; tick += step) {
ticks.push(tick);
}
return ticks;
}
export function timelineHeight(rowCount: number): number {
return TIMELINE_AXIS_HEIGHT + rowCount * TIMELINE_ROW_HEIGHT + TIMELINE_BOTTOM_PADDING;
}
@@ -0,0 +1,162 @@
import type { TguPlan, TguPlatform } from "../../model/tguTypes";
import type { TguPlanUi, TguPlatformUi, TimelineRange, TimelineRow } from "../../model/timelineTypes";
const PROBLEM_STATUSES = new Set(["REJECTED", "EXPIRED", "START_AMBIGUOUS"]);
export function mapPlans(plans: TguPlan[], platforms: TguPlatform[]): TguPlanUi[] {
const platformByNorad = buildPlatformByNorad(platforms);
return plans
.map((plan) => {
const startMs = new Date(plan.startTime).getTime();
const endMs = new Date(plan.endTime).getTime();
const platform = platformByNorad.get(plan.spacecraftId);
return {
...plan,
startMs,
endMs,
platform: platform ? toPlatformUi(plan.spacecraftId, platform, 0, false) : undefined
};
})
.filter((plan) => Number.isFinite(plan.startMs) && Number.isFinite(plan.endMs));
}
export function buildPlatformsForInterval(
platforms: TguPlatform[],
plans: TguPlanUi[],
range: TimelineRange
): TguPlatformUi[] {
const intervalPlans = filterPlansByRange(plans, range);
const platformByNorad = buildPlatformByNorad(platforms);
const planStats = new Map<string, { count: number; hasProblemStatus: boolean }>();
for (const plan of intervalPlans) {
const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false };
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 stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false };
return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus);
});
const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
for (const [spacecraftId, stats] of planStats.entries()) {
if (!knownSpacecraft.has(spacecraftId)) {
platformItems.push({
spacecraftId,
noradId: spacecraftId,
planCount: stats.count,
hasProblemStatus: stats.hasProblemStatus
});
}
}
return platformItems.sort((a, b) => platformLabel(a).localeCompare(platformLabel(b), "ru"));
}
export function buildTimelineRows(
plans: TguPlanUi[],
platforms: TguPlatformUi[],
range: TimelineRange,
selectedSpacecraftId?: string
): TimelineRow[] {
const platformBySpacecraft = new Map(platforms.map((platform) => [platform.spacecraftId, platform]));
const rowsBySpacecraft = new Map<string, TimelineRow>();
for (const plan of filterPlansByRange(plans, range)) {
if (selectedSpacecraftId && plan.spacecraftId !== selectedSpacecraftId) {
continue;
}
const row =
rowsBySpacecraft.get(plan.spacecraftId) ??
{
spacecraftId: plan.spacecraftId,
platform: platformBySpacecraft.get(plan.spacecraftId),
plans: []
};
row.plans.push(plan);
rowsBySpacecraft.set(plan.spacecraftId, row);
}
for (const platform of platforms) {
if (selectedSpacecraftId && platform.spacecraftId !== selectedSpacecraftId) {
continue;
}
if (!rowsBySpacecraft.has(platform.spacecraftId) && platform.planCount > 0) {
rowsBySpacecraft.set(platform.spacecraftId, {
spacecraftId: platform.spacecraftId,
platform,
plans: []
});
}
}
return Array.from(rowsBySpacecraft.values())
.map((row) => ({
...row,
platform: row.platform ?? platformBySpacecraft.get(row.spacecraftId),
plans: [...row.plans].sort((a, b) => a.startMs - b.startMs)
}))
.sort((a, b) => platformLabel(a.platform, a.spacecraftId).localeCompare(platformLabel(b.platform, b.spacecraftId), "ru"));
}
export function filterPlatforms(platforms: TguPlatformUi[], query: string): TguPlatformUi[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return platforms;
return platforms.filter((platform) => {
const searchText = [
platform.name,
platform.noradId,
platform.spacecraftId,
platform.mission,
platform.status
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return searchText.includes(normalized);
});
}
export function filterPlansByRange(plans: TguPlanUi[], range: TimelineRange): TguPlanUi[] {
return plans.filter((plan) => plan.endMs >= range.fromMs && plan.startMs <= range.toMs);
}
export function platformLabel(platform?: TguPlatformUi, fallback?: string): string {
return platform?.name || platform?.noradId || fallback || platform?.spacecraftId || "КА";
}
function buildPlatformByNorad(platforms: TguPlatform[]): Map<string, TguPlatform> {
const result = new Map<string, TguPlatform>();
for (const platform of platforms) {
if (platform.noradId !== null && platform.noradId !== undefined) {
result.set(String(platform.noradId), platform);
}
}
return result;
}
function toPlatformUi(
spacecraftId: string,
platform: TguPlatform,
planCount: number,
hasProblemStatus: boolean
): TguPlatformUi {
return {
spacecraftId,
name: platform.name ?? undefined,
noradId: platform.noradId === null || platform.noradId === undefined ? undefined : String(platform.noradId),
status: platform.status ?? undefined,
mission: platform.mission ?? undefined,
planCount,
hasProblemStatus,
platform
};
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/theme.css";
import "./styles/tgu-planning.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
@@ -0,0 +1,56 @@
export type TguPlanStatus =
| "ACCEPTED"
| "REJECTED"
| "WAITING_DECISION"
| "PLANNED"
| "ISSUING"
| "EXPIRED"
| "SUPERSEDED"
| "START_AMBIGUOUS"
| string;
export type TguPlanDecision = "ACCEPTED" | "REJECTED";
export type TguPlan = {
planId: string;
spacecraftId: string;
startTime: string;
endTime: string;
kppId: string;
status: TguPlanStatus;
};
export type TguPlatform = {
id?: string | null;
businessKey?: string | null;
name?: string | null;
status?: string | null;
noradId?: number | string | null;
mission?: string | null;
validFrom?: string | null;
validTo?: string | null;
};
export type TguPlanDecisionMessage = {
eventId: string;
spacecraftId: string;
planId: string;
attemptId: string;
decision: TguPlanDecision;
decisionTime: string;
reason?: string | null;
};
export type TguApiErrorKind = "network" | "http";
export class TguApiError extends Error {
readonly kind: TguApiErrorKind;
readonly status?: number;
constructor(message: string, kind: TguApiErrorKind, status?: number) {
super(message);
this.name = "TguApiError";
this.kind = kind;
this.status = status;
}
}
@@ -0,0 +1,50 @@
import type { TguPlan, TguPlanStatus, TguPlatform } from "./tguTypes";
export type TguPlatformUi = {
spacecraftId: string;
name?: string;
noradId?: string;
status?: string;
mission?: string;
planCount: number;
hasProblemStatus: boolean;
platform?: TguPlatform;
};
export type TguPlanUi = TguPlan & {
startMs: number;
endMs: number;
platform?: TguPlatformUi;
};
export type TimelineRange = {
fromMs: number;
toMs: number;
};
export type TimelineRow = {
spacecraftId: string;
platform?: TguPlatformUi;
plans: TguPlanUi[];
};
export type TimelinePlanSegment = {
plan: TguPlanUi;
rowIndex: number;
x: number;
x2: number;
width: number;
};
export type TimelineLink = {
spacecraftId: string;
fromPlanId: string;
toPlanId: string;
};
export type TimelineStatusStyle = {
status: TguPlanStatus;
label: string;
color: string;
className: string;
};
@@ -0,0 +1,569 @@
.tgu-app-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: 100%;
background:
linear-gradient(180deg, rgba(104, 195, 189, 0.06), transparent 22rem),
var(--bg-base);
}
.tgu-toolbar {
border-bottom: 1px solid var(--line);
background: rgba(23, 26, 28, 0.96);
box-shadow: var(--shadow);
z-index: 2;
}
.tgu-toolbar__row {
display: flex;
align-items: end;
gap: 0.75rem;
min-height: 4.4rem;
padding: 0.75rem 1rem 0.65rem;
overflow-x: auto;
}
.tgu-toolbar__brand {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 13.5rem;
padding-right: 0.5rem;
}
.tgu-toolbar__mark {
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--accent);
border-radius: 50%;
box-shadow: 0 0 18px rgba(104, 195, 189, 0.42);
}
.tgu-toolbar__title {
font-size: 0.96rem;
font-weight: 700;
}
.tgu-toolbar__subtitle {
margin-top: 0.1rem;
color: var(--text-dim);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.68rem;
text-transform: uppercase;
}
.tgu-field {
display: grid;
gap: 0.25rem;
color: var(--text-muted);
font-size: 0.72rem;
}
.tgu-field input,
.tgu-search input {
min-height: 2rem;
border: 1px solid var(--line);
border-radius: 6px;
background: #101214;
color: var(--text);
outline: none;
}
.tgu-field input {
width: 12.8rem;
padding: 0.38rem 0.55rem;
}
.tgu-field input:focus,
.tgu-search input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(104, 195, 189, 0.16);
}
.tgu-button {
min-height: 2rem;
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.38rem 0.72rem;
background: var(--bg-panel-2);
color: var(--text);
white-space: nowrap;
}
.tgu-button:hover:not(:disabled) {
border-color: #4e5a5d;
background: var(--bg-elevated);
}
.tgu-button--primary {
border-color: rgba(104, 195, 189, 0.58);
background: #214341;
color: #eafffd;
}
.tgu-button--accept {
border-color: rgba(47, 158, 68, 0.65);
background: rgba(47, 158, 68, 0.16);
}
.tgu-button--reject {
border-color: rgba(214, 69, 69, 0.7);
background: rgba(214, 69, 69, 0.14);
}
.tgu-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.48rem 0.82rem;
padding: 0 1rem 0.75rem;
color: var(--text-muted);
font-size: 0.74rem;
}
.tgu-legend__item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.tgu-legend__swatch {
width: 1.05rem;
height: 0.46rem;
border-radius: 999px;
background: #79828d;
}
.tgu-legend__note {
color: var(--accent-strong);
}
.status-accepted {
background-color: #2f9e44;
}
.status-rejected {
background-color: #d64545;
}
.status-waiting {
background-color: #f0ad2e;
}
.status-planned {
background-color: #8d99a6;
}
.status-issuing {
background-color: #2f80ed;
}
.status-expired {
background-color: #495057;
}
.status-superseded {
background-color: #c7ced6;
}
.status-ambiguous {
background-color: #b42318;
outline: 1px dashed #ffd1d1;
}
.status-unknown {
background-color: #79828d;
}
.tgu-workspace {
display: grid;
grid-template-columns: 18.5rem minmax(0, 1fr) 22rem;
min-width: 0;
min-height: 0;
}
.tgu-sidebar,
.tgu-details {
min-height: 0;
background: var(--bg-panel);
}
.tgu-sidebar {
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
}
.tgu-panel-heading {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 3rem;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--line-soft);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
}
.tgu-link-button {
border: 0;
background: transparent;
color: var(--accent);
font-size: 0.78rem;
}
.tgu-search {
padding: 0.75rem 0.8rem;
}
.tgu-search input {
width: 100%;
padding: 0.42rem 0.65rem;
}
.tgu-spacecraft-list {
display: grid;
align-content: start;
gap: 0.35rem;
min-height: 0;
overflow: auto;
padding: 0 0.55rem 0.9rem;
}
.tgu-spacecraft {
display: grid;
grid-template-columns: 0.58rem minmax(0, 1fr) auto;
gap: 0.55rem;
align-items: center;
width: 100%;
min-height: 4.35rem;
border: 1px solid transparent;
border-radius: 7px;
padding: 0.55rem 0.62rem;
background: transparent;
color: var(--text);
text-align: left;
}
.tgu-spacecraft:hover,
.tgu-spacecraft.is-selected {
border-color: var(--line);
background: var(--bg-panel-2);
}
.tgu-spacecraft.is-selected {
border-color: rgba(104, 195, 189, 0.6);
}
.tgu-spacecraft__problem {
width: 0.48rem;
height: 0.48rem;
border-radius: 50%;
background: #465154;
}
.tgu-spacecraft__problem.is-problem {
background: var(--danger);
box-shadow: 0 0 12px rgba(214, 69, 69, 0.55);
}
.tgu-spacecraft__body {
display: grid;
min-width: 0;
gap: 0.18rem;
}
.tgu-spacecraft__name {
overflow: hidden;
font-size: 0.86rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgu-spacecraft__meta,
.tgu-spacecraft__status {
overflow: hidden;
color: var(--text-dim);
font-size: 0.72rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.tgu-spacecraft__count {
display: grid;
place-items: center;
min-width: 1.75rem;
height: 1.5rem;
border-radius: 5px;
background: #101214;
color: var(--accent-strong);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.78rem;
}
.tgu-center {
min-width: 0;
min-height: 0;
overflow: auto;
background: #121516;
}
.tgu-timeline {
min-width: 48rem;
min-height: 100%;
padding: 0.85rem;
}
.tgu-timeline__svg {
display: block;
width: 100%;
min-height: 26rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #151819;
}
.tgu-timeline__background {
fill: #151819;
}
.tgu-timeline__labels-background {
fill: #181c1e;
}
.tgu-timeline__axis,
.tgu-timeline__grid {
stroke: #343c3f;
stroke-width: 1;
}
.tgu-timeline__grid {
stroke-dasharray: 3 5;
}
.tgu-timeline__tick-date {
fill: #d2dddd;
font-size: 12px;
font-weight: 650;
}
.tgu-timeline__tick-time,
.tgu-timeline__row-meta {
fill: #79878a;
font-size: 11px;
}
.tgu-timeline__now {
stroke: #f46e4f;
stroke-width: 1.7;
stroke-dasharray: 5 5;
}
.tgu-timeline__now-label {
fill: #f46e4f;
font-size: 12px;
font-weight: 700;
}
.tgu-timeline__row {
fill: #151819;
stroke: #22282a;
stroke-width: 1;
}
.tgu-timeline__row--alt {
fill: #171b1d;
}
.tgu-timeline__row-title {
fill: #e2e9e9;
font-size: 13px;
font-weight: 700;
}
.tgu-timeline__arrow {
fill: none;
stroke: #8aa2a6;
stroke-width: 1.5;
opacity: 0.8;
}
.tgu-timeline-plan {
cursor: pointer;
}
.tgu-timeline-plan__bar {
stroke-linecap: round;
stroke-width: 13;
}
.tgu-timeline-plan__selection {
stroke: #efffff;
stroke-linecap: round;
stroke-width: 19;
opacity: 0.85;
}
.tgu-timeline-plan__label {
fill: #101214;
font-size: 11px;
font-weight: 700;
pointer-events: none;
}
.tgu-details {
display: flex;
flex-direction: column;
gap: 0.9rem;
overflow: auto;
border-left: 1px solid var(--line);
padding: 1rem;
}
.tgu-details--empty {
align-items: center;
justify-content: center;
}
.tgu-details__placeholder {
display: grid;
justify-items: center;
gap: 0.75rem;
color: var(--text-dim);
text-align: center;
}
.tgu-details__placeholder-icon {
width: 2.2rem;
height: 1rem;
border: 2px solid var(--line);
border-radius: 999px;
}
.tgu-details__header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 0.8rem;
}
.tgu-details__eyebrow {
color: var(--text-dim);
font-size: 0.68rem;
text-transform: uppercase;
}
.tgu-details h2 {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
font-size: 1rem;
line-height: 1.25;
}
.tgu-icon-button {
display: grid;
place-items: center;
width: 1.8rem;
height: 1.8rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg-panel-2);
color: var(--text-muted);
}
.tgu-status-pill {
align-self: flex-start;
border-radius: 999px;
padding: 0.22rem 0.65rem;
color: #101214;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.72rem;
font-weight: 800;
}
.tgu-details-grid {
display: grid;
gap: 0.72rem;
margin: 0;
}
.tgu-details-grid dt {
margin-bottom: 0.18rem;
color: var(--text-dim);
font-size: 0.68rem;
text-transform: uppercase;
}
.tgu-details-grid dd {
margin: 0;
overflow-wrap: anywhere;
color: var(--text);
font-size: 0.84rem;
}
.tgu-details__actions {
display: flex;
gap: 0.6rem;
padding-top: 0.35rem;
}
.tgu-alert {
border: 1px solid var(--line);
border-radius: 7px;
padding: 0.62rem 0.72rem;
font-size: 0.8rem;
line-height: 1.35;
}
.tgu-toolbar > .tgu-alert {
margin: 0 1rem 0.75rem;
}
.tgu-alert--danger {
border-color: rgba(214, 69, 69, 0.48);
background: rgba(214, 69, 69, 0.12);
color: #ffd9d9;
}
.tgu-alert--warning {
border-color: rgba(240, 173, 46, 0.48);
background: rgba(240, 173, 46, 0.12);
color: #ffe7b5;
}
.tgu-alert--success {
border-color: rgba(47, 158, 68, 0.48);
background: rgba(47, 158, 68, 0.12);
color: #c9f7d2;
}
.tgu-empty {
display: grid;
place-items: center;
min-height: 18rem;
padding: 2rem;
color: var(--text-dim);
text-align: center;
}
.tgu-empty--compact {
min-height: 5rem;
padding: 1rem;
font-size: 0.82rem;
}
@media (max-width: 1100px) {
.tgu-workspace {
grid-template-columns: 16rem minmax(0, 1fr);
}
.tgu-details {
grid-column: 1 / -1;
max-height: 18rem;
border-left: 0;
border-top: 1px solid var(--line);
}
}
@@ -0,0 +1,57 @@
:root {
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e7ecec;
background: #111315;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
--bg-base: #111315;
--bg-panel: #171a1c;
--bg-panel-2: #1f2325;
--bg-elevated: #262b2e;
--line: #30373a;
--line-soft: #252b2d;
--text: #e7ecec;
--text-muted: #a4b0b1;
--text-dim: #748183;
--accent: #68c3bd;
--accent-strong: #82ded8;
--danger: #d64545;
--warning: #f0ad2e;
--success: #2f9e44;
--shadow: 0 16px 42px rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
margin: 0;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
"/api": {
target: "http://localhost:7008",
changeOrigin: true
}
}
},
test: {
environment: "jsdom",
globals: true
}
});