feat(simulation): add Polygon live worker gateway
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
import type { PolygonRunState } from "./runArchive";
|
||||
|
||||
export interface PolygonWorkerStatus {
|
||||
workerId: string;
|
||||
available: boolean;
|
||||
controlAvailable: boolean;
|
||||
activeRunId: string | null;
|
||||
runState: PolygonRunState | null;
|
||||
providerIds: string[];
|
||||
isolation: {
|
||||
network: string;
|
||||
processIdentity: string;
|
||||
artifactPolicy: "d-only";
|
||||
};
|
||||
}
|
||||
|
||||
export interface PolygonVehicleState {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
observedAtUtc: string;
|
||||
hostMonotonicNs: number;
|
||||
simTimeNs: number;
|
||||
position: { x: number; y: number; z: number };
|
||||
orientation: { x: number; y: number; z: number; w: number };
|
||||
}
|
||||
|
||||
export class PolygonWorkerContractError extends Error {}
|
||||
|
||||
export class PolygonWorkerApiError extends Error {
|
||||
constructor(message: string, readonly status: number | null = null) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type PolygonWorkerFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RUN_STATES = new Set<PolygonRunState>([
|
||||
"admitted",
|
||||
"starting",
|
||||
"running",
|
||||
"paused",
|
||||
"stopping",
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
]);
|
||||
const STATUS_KEYS = new Set([
|
||||
"schema_version",
|
||||
"worker_id",
|
||||
"transport",
|
||||
"mode",
|
||||
"available",
|
||||
"control_available",
|
||||
"active_run_id",
|
||||
"run_state",
|
||||
"provider_ids",
|
||||
"isolation",
|
||||
"authority",
|
||||
]);
|
||||
const ISOLATION_KEYS = new Set(["network", "process_identity", "artifact_policy"]);
|
||||
const AUTHORITY_KEYS = new Set([
|
||||
"scope",
|
||||
"actuator_authority",
|
||||
"direct_actuator_setpoints_allowed",
|
||||
]);
|
||||
const VEHICLE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"run_id",
|
||||
"sequence",
|
||||
"observed_at_utc",
|
||||
"host_monotonic_ns",
|
||||
"sim_time_ns",
|
||||
"frame_id",
|
||||
"child_frame_id",
|
||||
"pose",
|
||||
"source",
|
||||
"safety",
|
||||
]);
|
||||
const POSE_KEYS = new Set(["position_m", "orientation_xyzw"]);
|
||||
const POSITION_KEYS = new Set(["x", "y", "z"]);
|
||||
const ORIENTATION_KEYS = new Set(["x", "y", "z", "w"]);
|
||||
const SOURCE_KEYS = new Set(["provider", "topic", "signal", "quality"]);
|
||||
const SAFETY_KEYS = new Set([
|
||||
"scope",
|
||||
"actuator_authority",
|
||||
"navigation_or_safety_accepted",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть объектом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
label: string,
|
||||
) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит неизвестные или отсутствующие поля.`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, label: string, maximum = 512): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть строкой.`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > maximum) {
|
||||
throw new PolygonWorkerContractError(`${label} имеет недопустимую длину.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function safeId(value: unknown, label: string): string {
|
||||
const identifier = stringValue(value, label, 128);
|
||||
if (!SAFE_ID.test(identifier)) {
|
||||
throw new PolygonWorkerContractError(`${label} содержит небезопасный идентификатор.`);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть неотрицательным целым.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function finiteValue(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) >= 1e9) {
|
||||
throw new PolygonWorkerContractError(`${label} должен быть конечным числом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function decodePolygonWorkerStatus(payload: unknown): PolygonWorkerStatus {
|
||||
const value = record(payload, "Статус Simulation Worker");
|
||||
exactKeys(value, STATUS_KEYS, "Статус Simulation Worker");
|
||||
if (
|
||||
value.schema_version !== "missioncore.simulation-worker-status/v1" ||
|
||||
value.transport !== "unix" ||
|
||||
value.mode !== "simulation"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Статус Simulation Worker имеет неизвестную схему.");
|
||||
}
|
||||
const isolation = record(value.isolation, "isolation");
|
||||
exactKeys(isolation, ISOLATION_KEYS, "isolation");
|
||||
if (isolation.artifact_policy !== "d-only") {
|
||||
throw new PolygonWorkerContractError("Simulation Worker нарушает D-only политику.");
|
||||
}
|
||||
const authority = record(value.authority, "authority");
|
||||
exactKeys(authority, AUTHORITY_KEYS, "authority");
|
||||
if (
|
||||
authority.scope !== "virtual-only" ||
|
||||
authority.actuator_authority !== false ||
|
||||
authority.direct_actuator_setpoints_allowed !== false
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Simulation Worker вышел за virtual-only границу.");
|
||||
}
|
||||
if (!Array.isArray(value.provider_ids) || value.provider_ids.length > 32) {
|
||||
throw new PolygonWorkerContractError("provider_ids должен быть ограниченным массивом.");
|
||||
}
|
||||
const providerIds = value.provider_ids.map((item, index) =>
|
||||
safeId(item, `provider_ids[${index}]`));
|
||||
const activeRunId = value.active_run_id === null
|
||||
? null
|
||||
: safeId(value.active_run_id, "active_run_id");
|
||||
const runStateValue = value.run_state === null
|
||||
? null
|
||||
: stringValue(value.run_state, "run_state", 32);
|
||||
if (
|
||||
(runStateValue !== null && !RUN_STATES.has(runStateValue as PolygonRunState)) ||
|
||||
(activeRunId === null) !== (runStateValue === null)
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Активный прогон и его состояние противоречат друг другу.");
|
||||
}
|
||||
return {
|
||||
workerId: safeId(value.worker_id, "worker_id"),
|
||||
available: booleanValue(value.available, "available"),
|
||||
controlAvailable: booleanValue(value.control_available, "control_available"),
|
||||
activeRunId,
|
||||
runState: runStateValue as PolygonRunState | null,
|
||||
providerIds,
|
||||
isolation: {
|
||||
network: stringValue(isolation.network, "isolation.network", 64),
|
||||
processIdentity: safeId(isolation.process_identity, "isolation.process_identity"),
|
||||
artifactPolicy: "d-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePolygonVehicleState(payload: unknown): PolygonVehicleState {
|
||||
const value = record(payload, "VehicleState");
|
||||
exactKeys(value, VEHICLE_KEYS, "VehicleState");
|
||||
if (
|
||||
value.schema_version !== "missioncore.vehicle-state/v1" ||
|
||||
value.frame_id !== "map_enu" ||
|
||||
value.child_frame_id !== "base_link_flu"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("VehicleState имеет неизвестную схему координат.");
|
||||
}
|
||||
const pose = record(value.pose, "pose");
|
||||
exactKeys(pose, POSE_KEYS, "pose");
|
||||
const position = record(pose.position_m, "position_m");
|
||||
exactKeys(position, POSITION_KEYS, "position_m");
|
||||
const orientation = record(pose.orientation_xyzw, "orientation_xyzw");
|
||||
exactKeys(orientation, ORIENTATION_KEYS, "orientation_xyzw");
|
||||
const source = record(value.source, "source");
|
||||
exactKeys(source, SOURCE_KEYS, "source");
|
||||
if (
|
||||
source.provider !== "gazebo" ||
|
||||
source.signal !== "ground-truth" ||
|
||||
source.quality !== "diagnostic"
|
||||
) {
|
||||
throw new PolygonWorkerContractError("Live-сигнал не маркирован как Gazebo diagnostic.");
|
||||
}
|
||||
const safety = record(value.safety, "safety");
|
||||
exactKeys(safety, SAFETY_KEYS, "safety");
|
||||
if (
|
||||
safety.scope !== "virtual-only" ||
|
||||
safety.actuator_authority !== false ||
|
||||
safety.navigation_or_safety_accepted !== false
|
||||
) {
|
||||
throw new PolygonWorkerContractError("VehicleState нарушает virtual-only границу.");
|
||||
}
|
||||
const observedAtUtc = stringValue(value.observed_at_utc, "observed_at_utc", 64);
|
||||
if (!Number.isFinite(Date.parse(observedAtUtc))) {
|
||||
throw new PolygonWorkerContractError("observed_at_utc должен быть ISO-датой.");
|
||||
}
|
||||
return {
|
||||
runId: safeId(value.run_id, "run_id"),
|
||||
sequence: integerValue(value.sequence, "sequence"),
|
||||
observedAtUtc,
|
||||
hostMonotonicNs: integerValue(value.host_monotonic_ns, "host_monotonic_ns"),
|
||||
simTimeNs: integerValue(value.sim_time_ns, "sim_time_ns"),
|
||||
position: {
|
||||
x: finiteValue(position.x, "position.x"),
|
||||
y: finiteValue(position.y, "position.y"),
|
||||
z: finiteValue(position.z, "position.z"),
|
||||
},
|
||||
orientation: {
|
||||
x: finiteValue(orientation.x, "orientation.x"),
|
||||
y: finiteValue(orientation.y, "orientation.y"),
|
||||
z: finiteValue(orientation.z, "orientation.z"),
|
||||
w: finiteValue(orientation.w, "orientation.w"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().startsWith("application/json")) {
|
||||
throw new PolygonWorkerContractError("Polygon Worker API вернул не JSON.");
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new PolygonWorkerContractError("Polygon Worker API вернул повреждённый JSON.");
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(body: unknown, fallback: string, status: number): PolygonWorkerApiError {
|
||||
if (isRecord(body) && typeof body.detail === "string") {
|
||||
const detail = body.detail.trim();
|
||||
if (detail && detail.length <= 1_000) return new PolygonWorkerApiError(detail, status);
|
||||
}
|
||||
return new PolygonWorkerApiError(`${fallback} HTTP ${status}.`, status);
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
fetcher: PolygonWorkerFetch,
|
||||
): Promise<unknown> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(url, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new PolygonWorkerApiError(fallback);
|
||||
}
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, fallback, response.status);
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function fetchPolygonWorkerStatus({
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
} = {}): Promise<PolygonWorkerStatus> {
|
||||
return decodePolygonWorkerStatus(await requestJson(
|
||||
"/api/v1/polygon/worker",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
"Не удалось получить статус Simulation Worker.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchPolygonVehicleState({
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
} = {}): Promise<PolygonVehicleState> {
|
||||
return decodePolygonVehicleState(await requestJson(
|
||||
"/api/v1/polygon/worker/live",
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
"Не удалось получить live-состояние ровера.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
||||
export async function startPolygonWorker({
|
||||
idempotencyKey,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
idempotencyKey: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
}): Promise<PolygonWorkerStatus> {
|
||||
return decodePolygonWorkerStatus(await requestJson(
|
||||
"/api/v1/polygon/worker/runs",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify({ scenario_id: "stock-rover-ackermann" }),
|
||||
signal,
|
||||
},
|
||||
"Не удалось запустить Simulation Worker.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
|
||||
export async function stopPolygonWorker(
|
||||
runId: string,
|
||||
{
|
||||
idempotencyKey,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
idempotencyKey: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: PolygonWorkerFetch;
|
||||
},
|
||||
): Promise<PolygonWorkerStatus> {
|
||||
if (!SAFE_ID.test(runId)) {
|
||||
throw new PolygonWorkerContractError("Некорректный идентификатор активного прогона.");
|
||||
}
|
||||
return decodePolygonWorkerStatus(await requestJson(
|
||||
`/api/v1/polygon/worker/runs/${encodeURIComponent(runId)}/stop`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"Idempotency-Key": idempotencyKey,
|
||||
},
|
||||
body: "{}",
|
||||
signal,
|
||||
},
|
||||
"Не удалось остановить Simulation Worker.",
|
||||
fetcher,
|
||||
));
|
||||
}
|
||||
@@ -135,10 +135,10 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
{
|
||||
id: "polygon-run-internal",
|
||||
root: "system",
|
||||
label: "Прогон Полигона",
|
||||
title: "Прогон Полигона",
|
||||
eyebrow: "ПОЛИГОН / UI-0",
|
||||
description: "Read-only доказательства квалификационного прогона PX4/Gazebo.",
|
||||
label: "Полигон",
|
||||
title: "Полигон",
|
||||
eyebrow: "ПОЛИГОН / UI-1",
|
||||
description: "Live Simulation Worker и доказательства прогонов PX4/Gazebo.",
|
||||
icon: "activity",
|
||||
kind: "polygon-run",
|
||||
internalOnly: true,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@media (max-width: 1280px) {
|
||||
.overview-grid,
|
||||
.mission-layout,
|
||||
.polygon-live-layout,
|
||||
.polygon-run-layout,
|
||||
.polygon-run-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -83,6 +84,15 @@
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.polygon-live-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.polygon-live-heading > div:last-child {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.workspace-lead__note,
|
||||
.workspace-lead__status {
|
||||
max-width: none;
|
||||
@@ -151,6 +161,15 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.polygon-live-heading > div:last-child {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.polygon-live-map {
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
.polygon-run-event-list article {
|
||||
grid-template-columns: 2.3rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,184 @@
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.polygon-live-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.polygon-live-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.polygon-live-heading h2,
|
||||
.polygon-live-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.polygon-live-heading h2 {
|
||||
margin-top: 0.32rem;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.polygon-live-heading p {
|
||||
max-width: 42rem;
|
||||
margin-top: 0.42rem;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.polygon-live-heading > div:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.polygon-live-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(22rem, 1.45fr) minmax(17rem, 0.55fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.polygon-live-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 19rem;
|
||||
border: 1px solid var(--station-hairline);
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
radial-gradient(circle at 48% 46%, rgb(var(--nodedc-accent-rgb) / 0.055), transparent 45%),
|
||||
rgb(255 255 255 / 0.018);
|
||||
}
|
||||
|
||||
.polygon-live-map svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.polygon-live-map pattern path {
|
||||
fill: none;
|
||||
stroke: rgb(255 255 255 / 0.045);
|
||||
stroke-width: 0.25;
|
||||
}
|
||||
|
||||
.polygon-live-map text {
|
||||
fill: var(--nodedc-text-muted);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 3px;
|
||||
}
|
||||
|
||||
.polygon-live-axis-x,
|
||||
.polygon-live-axis-y {
|
||||
stroke: rgb(255 255 255 / 0.28);
|
||||
stroke-width: 0.45;
|
||||
}
|
||||
|
||||
.polygon-live-trajectory {
|
||||
fill: none;
|
||||
stroke: rgb(var(--nodedc-accent-rgb) / 0.58);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 0.65;
|
||||
}
|
||||
|
||||
.polygon-live-rover {
|
||||
fill: rgb(var(--nodedc-accent-rgb));
|
||||
stroke: rgb(var(--nodedc-on-accent-rgb));
|
||||
stroke-width: 0.35;
|
||||
}
|
||||
|
||||
.polygon-live-map > div {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: min(22rem, 76%);
|
||||
gap: 0.35rem;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.polygon-live-map > div strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.polygon-live-map > div span,
|
||||
.polygon-live-map > small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.58rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.polygon-live-map > small {
|
||||
position: absolute;
|
||||
right: 0.8rem;
|
||||
bottom: 0.7rem;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.polygon-live-telemetry {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.polygon-live-telemetry > div:not(.polygon-live-boundary) {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.028);
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.polygon-live-telemetry span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
|
||||
.polygon-live-telemetry strong {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 0.62rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.polygon-live-boundary {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
border: 1px solid rgb(var(--nodedc-success-rgb) / 0.18);
|
||||
border-radius: 0.8rem;
|
||||
background: rgb(var(--nodedc-success-rgb) / 0.035);
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.polygon-live-boundary p,
|
||||
.polygon-live-error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.polygon-live-error {
|
||||
border-radius: 0.65rem;
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.08);
|
||||
padding: 0.6rem;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.polygon-run-message {
|
||||
display: grid;
|
||||
min-height: 18rem;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchPolygonVehicleState,
|
||||
fetchPolygonWorkerStatus,
|
||||
startPolygonWorker,
|
||||
stopPolygonWorker,
|
||||
type PolygonVehicleState,
|
||||
type PolygonWorkerStatus,
|
||||
} from "../core/polygon/liveWorker";
|
||||
|
||||
function message(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Simulation Worker не подтвердил операцию.";
|
||||
}
|
||||
|
||||
function yawDegrees(state: PolygonVehicleState): number {
|
||||
const { x, y, z, w } = state.orientation;
|
||||
return Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180 / Math.PI;
|
||||
}
|
||||
|
||||
function trajectoryPoints(states: PolygonVehicleState[]): {
|
||||
points: string;
|
||||
roverX: number;
|
||||
roverY: number;
|
||||
} {
|
||||
if (!states.length) return { points: "", roverX: 50, roverY: 50 };
|
||||
const xs = states.map(({ position }) => position.x);
|
||||
const ys = states.map(({ position }) => position.y);
|
||||
const minimumX = Math.min(...xs);
|
||||
const maximumX = Math.max(...xs);
|
||||
const minimumY = Math.min(...ys);
|
||||
const maximumY = Math.max(...ys);
|
||||
const span = Math.max(maximumX - minimumX, maximumY - minimumY, 4);
|
||||
const centerX = (minimumX + maximumX) / 2;
|
||||
const centerY = (minimumY + maximumY) / 2;
|
||||
const project = (state: PolygonVehicleState) => ({
|
||||
x: 50 + ((state.position.x - centerX) / span) * 80,
|
||||
y: 50 - ((state.position.y - centerY) / span) * 80,
|
||||
});
|
||||
const projected = states.map(project);
|
||||
const rover = projected[projected.length - 1];
|
||||
return {
|
||||
points: projected.map(({ x, y }) => `${x.toFixed(2)},${y.toFixed(2)}`).join(" "),
|
||||
roverX: rover.x,
|
||||
roverY: rover.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function PolygonLivePanel() {
|
||||
const [worker, setWorker] = useState<PolygonWorkerStatus | null>(null);
|
||||
const [live, setLive] = useState<PolygonVehicleState | null>(null);
|
||||
const [trajectory, setTrajectory] = useState<PolygonVehicleState[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionBusy) return;
|
||||
const controller = new AbortController();
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await fetchPolygonWorkerStatus({ signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setWorker(status);
|
||||
setError(null);
|
||||
if (status.available && status.activeRunId && status.runState === "running") {
|
||||
const state = await fetchPolygonVehicleState({ signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setLive(state);
|
||||
setTrajectory((current) => {
|
||||
const sameRun = current[0]?.runId === state.runId;
|
||||
const next = sameRun ? [...current, state] : [state];
|
||||
return next.slice(-120);
|
||||
});
|
||||
} else {
|
||||
setLive(null);
|
||||
if (!status.activeRunId) setTrajectory([]);
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(message(pollError));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = window.setTimeout(() => void poll(), 1_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [actionBusy, generation]);
|
||||
|
||||
const projected = useMemo(() => trajectoryPoints(trajectory), [trajectory]);
|
||||
const runActive = Boolean(worker?.activeRunId);
|
||||
|
||||
const runAction = async () => {
|
||||
if (!worker?.controlAvailable || actionBusy) return;
|
||||
setActionBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const idempotencyKey = crypto.randomUUID();
|
||||
const next = worker.activeRunId
|
||||
? await stopPolygonWorker(worker.activeRunId, { idempotencyKey })
|
||||
: await startPolygonWorker({ idempotencyKey });
|
||||
setWorker(next);
|
||||
if (!next.activeRunId) {
|
||||
setLive(null);
|
||||
setTrajectory([]);
|
||||
}
|
||||
setGeneration((value) => value + 1);
|
||||
} catch (actionError) {
|
||||
setError(message(actionError));
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassSurface className="polygon-live-panel" padding="lg">
|
||||
<header className="polygon-live-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / LIVE</span>
|
||||
<h2>Stock Ackermann Rover</h2>
|
||||
<p>PX4/Gazebo исполняются на отдельном worker; браузер показывает канонический срез.</p>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge
|
||||
tone={worker?.available ? (runActive ? "accent" : "success") : "neutral"}
|
||||
>
|
||||
{worker?.available ? (runActive ? "Симуляция идёт" : "Worker готов") : "Worker offline"}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={runActive ? "secondary" : "primary"}
|
||||
disabled={!worker?.controlAvailable || actionBusy}
|
||||
onClick={() => void runAction()}
|
||||
>
|
||||
{actionBusy ? "Ожидаем PX4/Gazebo…" : runActive ? "Остановить" : "Запустить"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="polygon-live-layout">
|
||||
<div className="polygon-live-map" aria-label="Live-траектория ровера в ENU">
|
||||
<svg viewBox="0 0 100 100" role="img">
|
||||
<defs>
|
||||
<pattern id="polygon-grid" width="10" height="10" patternUnits="userSpaceOnUse">
|
||||
<path d="M 10 0 L 0 0 0 10" />
|
||||
</pattern>
|
||||
<radialGradient id="polygon-rover-glow">
|
||||
<stop offset="0" stopColor="rgb(var(--nodedc-accent-rgb))" stopOpacity="0.9" />
|
||||
<stop offset="1" stopColor="rgb(var(--nodedc-accent-rgb))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" fill="url(#polygon-grid)" />
|
||||
<line x1="8" y1="92" x2="25" y2="92" className="polygon-live-axis-x" />
|
||||
<line x1="8" y1="92" x2="8" y2="75" className="polygon-live-axis-y" />
|
||||
<text x="27" y="94">E</text>
|
||||
<text x="5" y="72">N</text>
|
||||
{projected.points && (
|
||||
<polyline points={projected.points} className="polygon-live-trajectory" />
|
||||
)}
|
||||
{live && (
|
||||
<g
|
||||
transform={
|
||||
`translate(${projected.roverX} ${projected.roverY}) rotate(${-yawDegrees(live)})`
|
||||
}
|
||||
>
|
||||
<circle r="7" fill="url(#polygon-rover-glow)" />
|
||||
<path d="M -3 -2.5 L 4 0 L -3 2.5 Z" className="polygon-live-rover" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
{!live && (
|
||||
<div>
|
||||
<strong>{worker?.available ? "Ровер не запущен" : "Нет связи с worker"}</strong>
|
||||
<span>После старта здесь появится ground-truth траектория из Gazebo.</span>
|
||||
</div>
|
||||
)}
|
||||
<small>map_enu · base_link_flu · diagnostic ground truth</small>
|
||||
</div>
|
||||
|
||||
<div className="polygon-live-telemetry">
|
||||
<div>
|
||||
<span>Прогон</span>
|
||||
<strong>{worker?.activeRunId ?? "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Sim time</span>
|
||||
<strong>{live ? `${(live.simTimeNs / 1e9).toFixed(2)} s` : "—"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Позиция ENU</span>
|
||||
<strong>
|
||||
{live
|
||||
? `${live.position.x.toFixed(2)} · ${live.position.y.toFixed(2)} · ${live.position.z.toFixed(2)} m`
|
||||
: "—"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Провайдеры</span>
|
||||
<strong>{worker?.providerIds.join(" · ") || "—"}</strong>
|
||||
</div>
|
||||
<div className="polygon-live-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Нет actuator authority. Live-поза диагностическая и пока не доказывает
|
||||
приёмку PX4/ROS 2 telemetry, navigation или safety.
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="polygon-live-error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type PolygonRunRoute,
|
||||
type PolygonRunState,
|
||||
} from "../core/polygon/runArchive";
|
||||
import { PolygonLivePanel } from "./PolygonLivePanel";
|
||||
|
||||
interface PolygonRunWorkspaceProps {
|
||||
route: PolygonRunRoute;
|
||||
@@ -113,6 +114,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
||||
<h2>Проверяем журнал прогона</h2>
|
||||
@@ -125,6 +127,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
|
||||
<h2>UI-0 не может открыть прогон</h2>
|
||||
@@ -144,6 +147,7 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
|
||||
<h2>Квалификационных прогонов пока нет</h2>
|
||||
@@ -156,13 +160,14 @@ export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const { run } = detail;
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<PolygonLivePanel />
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
|
||||
<h2>{run.runId}</h2>
|
||||
<p>
|
||||
Канонический журнал Mission Core. Экран не содержит lifecycle-операций,
|
||||
команд управления или доступа к физическим актуаторам.
|
||||
Канонический архив Mission Core. Lifecycle live-контура отделён от истории;
|
||||
команд физическим актуаторам и реального управления здесь нет.
|
||||
</p>
|
||||
</div>
|
||||
<div className="polygon-run-lead-status">
|
||||
|
||||
@@ -10,6 +10,13 @@ let fetchPolygonRunCatalog;
|
||||
let fetchPolygonRunDetail;
|
||||
let resolvePolygonRunRoute;
|
||||
let PolygonRunContractError;
|
||||
let decodePolygonWorkerStatus;
|
||||
let decodePolygonVehicleState;
|
||||
let fetchPolygonWorkerStatus;
|
||||
let fetchPolygonVehicleState;
|
||||
let startPolygonWorker;
|
||||
let stopPolygonWorker;
|
||||
let PolygonWorkerContractError;
|
||||
let workspaceById;
|
||||
let workspacesForRoot;
|
||||
|
||||
@@ -27,6 +34,15 @@ before(async () => {
|
||||
resolvePolygonRunRoute,
|
||||
PolygonRunContractError,
|
||||
} = await server.ssrLoadModule("/src/core/polygon/runArchive.ts"));
|
||||
({
|
||||
decodePolygonWorkerStatus,
|
||||
decodePolygonVehicleState,
|
||||
fetchPolygonWorkerStatus,
|
||||
fetchPolygonVehicleState,
|
||||
startPolygonWorker,
|
||||
stopPolygonWorker,
|
||||
PolygonWorkerContractError,
|
||||
} = await server.ssrLoadModule("/src/core/polygon/liveWorker.ts"));
|
||||
({ workspaceById, workspacesForRoot } = await server.ssrLoadModule("/src/productModel.ts"));
|
||||
});
|
||||
|
||||
@@ -148,6 +164,60 @@ function jsonResponse(payload, status = 200) {
|
||||
});
|
||||
}
|
||||
|
||||
function workerStatus(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.simulation-worker-status/v1",
|
||||
worker_id: "mission-gpu-s1",
|
||||
transport: "unix",
|
||||
mode: "simulation",
|
||||
available: true,
|
||||
control_available: true,
|
||||
active_run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
||||
run_state: "running",
|
||||
provider_ids: ["micro-xrce-dds-agent", "px4-gazebo-stock-rover"],
|
||||
isolation: {
|
||||
network: "loopback-only-netns",
|
||||
process_identity: "missioncore",
|
||||
artifact_policy: "d-only",
|
||||
},
|
||||
authority: {
|
||||
scope: "virtual-only",
|
||||
actuator_authority: false,
|
||||
direct_actuator_setpoints_allowed: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vehicleState(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.vehicle-state/v1",
|
||||
run_id: "s1c-6cb1495-20260724t180000z-aabbcc",
|
||||
sequence: 7,
|
||||
observed_at_utc: "2026-07-24T18:00:07Z",
|
||||
host_monotonic_ns: 123_456_789,
|
||||
sim_time_ns: 7_000_000_000,
|
||||
frame_id: "map_enu",
|
||||
child_frame_id: "base_link_flu",
|
||||
pose: {
|
||||
position_m: { x: 1.25, y: -0.5, z: 0.18 },
|
||||
orientation_xyzw: { x: 0, y: 0, z: 0.1, w: 0.995 },
|
||||
},
|
||||
source: {
|
||||
provider: "gazebo",
|
||||
topic: "/world/rover/dynamic_pose/info",
|
||||
signal: "ground-truth",
|
||||
quality: "diagnostic",
|
||||
},
|
||||
safety: {
|
||||
scope: "virtual-only",
|
||||
actuator_authority: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("UI-0 route is direct-only and does not add Polygon to system navigation", () => {
|
||||
assert.deepEqual(resolvePolygonRunRoute("?workspace=polygon-run"), {
|
||||
active: true,
|
||||
@@ -228,3 +298,64 @@ test("Polygon fetchers use bounded same-origin GET endpoints", async () => {
|
||||
assert.ok(calls.every(({ init }) => init.method === "GET"));
|
||||
assert.ok(calls.every(({ init }) => init.headers.Accept === "application/json"));
|
||||
});
|
||||
|
||||
test("Polygon Live decodes only D-only virtual diagnostic state", () => {
|
||||
const status = decodePolygonWorkerStatus(workerStatus());
|
||||
const live = decodePolygonVehicleState(vehicleState());
|
||||
|
||||
assert.equal(status.available, true);
|
||||
assert.equal(status.activeRunId, live.runId);
|
||||
assert.equal(status.isolation.artifactPolicy, "d-only");
|
||||
assert.deepEqual(live.position, { x: 1.25, y: -0.5, z: 0.18 });
|
||||
assert.throws(
|
||||
() => decodePolygonWorkerStatus(workerStatus({
|
||||
authority: {
|
||||
...workerStatus().authority,
|
||||
actuator_authority: true,
|
||||
},
|
||||
})),
|
||||
PolygonWorkerContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodePolygonVehicleState(vehicleState({
|
||||
source: {
|
||||
...vehicleState().source,
|
||||
quality: "accepted",
|
||||
},
|
||||
})),
|
||||
PolygonWorkerContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Polygon Live uses same-origin gateway and idempotent lifecycle requests", async () => {
|
||||
const calls = [];
|
||||
const stopped = workerStatus({
|
||||
active_run_id: null,
|
||||
run_state: null,
|
||||
provider_ids: [],
|
||||
});
|
||||
const fetcher = async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
if (String(url).endsWith("/live")) return jsonResponse(vehicleState());
|
||||
if (String(url).endsWith("/stop")) return jsonResponse(stopped);
|
||||
return jsonResponse(workerStatus());
|
||||
};
|
||||
|
||||
await fetchPolygonWorkerStatus({ fetcher });
|
||||
await fetchPolygonVehicleState({ fetcher });
|
||||
await startPolygonWorker({ idempotencyKey: "start-001", fetcher });
|
||||
await stopPolygonWorker("s1c-6cb1495-20260724t180000z-aabbcc", {
|
||||
idempotencyKey: "stop-001",
|
||||
fetcher,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.map(({ url }) => url), [
|
||||
"/api/v1/polygon/worker",
|
||||
"/api/v1/polygon/worker/live",
|
||||
"/api/v1/polygon/worker/runs",
|
||||
"/api/v1/polygon/worker/runs/s1c-6cb1495-20260724t180000z-aabbcc/stop",
|
||||
]);
|
||||
assert.equal(calls[2].init.headers["Idempotency-Key"], "start-001");
|
||||
assert.equal(calls[3].init.headers["Idempotency-Key"], "stop-001");
|
||||
assert.equal(calls[2].init.body, JSON.stringify({ scenario_id: "stock-rover-ackermann" }));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user