feat: introduce device plugin runtime boundary
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||
|
||||
interface DevicePluginHostValue {
|
||||
registry: DevicePluginRegistry;
|
||||
selection: RegisteredDeviceModel | null;
|
||||
selectionTransitionPending: boolean;
|
||||
selectionTransitionError: string | null;
|
||||
selectModel: (modelId: string) => Promise<boolean>;
|
||||
clearSelection: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
children,
|
||||
}: {
|
||||
plugins: readonly DeviceUiPlugin[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
|
||||
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
|
||||
const transitionInFlight = useRef(false);
|
||||
const deactivationHandlers = useRef(new Map<string, () => Promise<boolean>>());
|
||||
const selection = selectedModelId ? registry.resolveModel(selectedModelId) : null;
|
||||
|
||||
const registerDeactivation = useCallback(
|
||||
(pluginId: string, handler: () => Promise<boolean>) => {
|
||||
deactivationHandlers.current.set(pluginId, handler);
|
||||
return () => {
|
||||
if (deactivationHandlers.current.get(pluginId) === handler) {
|
||||
deactivationHandlers.current.delete(pluginId);
|
||||
}
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const transitionTo = useCallback(
|
||||
async (nextModelId: string | null): Promise<boolean> => {
|
||||
if (transitionInFlight.current) return false;
|
||||
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
|
||||
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
|
||||
}
|
||||
if (nextModelId === selectedModelId) return true;
|
||||
|
||||
transitionInFlight.current = true;
|
||||
setSelectionTransitionPending(true);
|
||||
setSelectionTransitionError(null);
|
||||
try {
|
||||
const current = selectedModelId ? registry.resolveModel(selectedModelId) : null;
|
||||
const deactivate = current
|
||||
? deactivationHandlers.current.get(current.plugin.manifest.metadata.id)
|
||||
: undefined;
|
||||
if (current && !deactivate) {
|
||||
setSelectionTransitionError(
|
||||
"Активный плагин не зарегистрировал безопасное завершение сессии.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (deactivate) {
|
||||
try {
|
||||
if (!(await deactivate())) {
|
||||
setSelectionTransitionError(
|
||||
"Плагин не подтвердил завершение текущей сессии.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
setSelectionTransitionError(
|
||||
"Не удалось безопасно завершить текущую сессию плагина.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setSelectedModelId(nextModelId);
|
||||
return true;
|
||||
} finally {
|
||||
transitionInFlight.current = false;
|
||||
setSelectionTransitionPending(false);
|
||||
}
|
||||
},
|
||||
[registry, selectedModelId],
|
||||
);
|
||||
|
||||
const deactivationRegistrars = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
registry.plugins.map((plugin) => [
|
||||
plugin.manifest.metadata.id,
|
||||
(handler: () => Promise<boolean>) =>
|
||||
registerDeactivation(plugin.manifest.metadata.id, handler),
|
||||
]),
|
||||
),
|
||||
[registerDeactivation, registry.plugins],
|
||||
);
|
||||
|
||||
const value = useMemo<DevicePluginHostValue>(
|
||||
() => ({
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionPending,
|
||||
selectionTransitionError,
|
||||
selectModel: (modelId) => transitionTo(modelId),
|
||||
clearSelection: () => transitionTo(null),
|
||||
}),
|
||||
[
|
||||
registry,
|
||||
selection,
|
||||
selectionTransitionError,
|
||||
selectionTransitionPending,
|
||||
transitionTo,
|
||||
],
|
||||
);
|
||||
|
||||
const runtimeTree = [...registry.plugins].reverse().reduce<ReactNode>((child, plugin) => {
|
||||
const RuntimeProvider = plugin.RuntimeProvider;
|
||||
return (
|
||||
<RuntimeProvider
|
||||
key={plugin.manifest.metadata.id}
|
||||
activeModel={
|
||||
selection?.plugin.manifest.metadata.id === plugin.manifest.metadata.id
|
||||
? selection.model
|
||||
: null
|
||||
}
|
||||
registerDeactivation={deactivationRegistrars.get(plugin.manifest.metadata.id)!}
|
||||
>
|
||||
{child}
|
||||
</RuntimeProvider>
|
||||
);
|
||||
}, children);
|
||||
|
||||
return (
|
||||
<DevicePluginHostContext.Provider value={value}>
|
||||
<IdleMissionRuntimeProvider>{runtimeTree}</IdleMissionRuntimeProvider>
|
||||
</DevicePluginHostContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDevicePluginHost(): DevicePluginHostValue {
|
||||
const value = useContext(DevicePluginHostContext);
|
||||
if (!value) {
|
||||
throw new Error("DevicePluginHostProvider не подключён в composition root.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export const DEVICE_PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1" as const;
|
||||
export const DEVICE_STATE_READ_ACTION_ID = "state.read" as const;
|
||||
|
||||
export interface DeviceCapability {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DevicePluginActionDefinition {
|
||||
id: string;
|
||||
mutating: boolean;
|
||||
secretFields: readonly string[];
|
||||
}
|
||||
|
||||
export interface DeviceModelDefinition {
|
||||
id: string;
|
||||
vendor: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
description: string;
|
||||
verified: boolean;
|
||||
capabilities: readonly DeviceCapability[];
|
||||
ui: {
|
||||
slot: "device.connection";
|
||||
componentKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginManifest {
|
||||
apiVersion: typeof DEVICE_PLUGIN_API_VERSION;
|
||||
kind: "DevicePlugin";
|
||||
metadata: {
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
};
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1";
|
||||
runtime: {
|
||||
backendEntrypoint: string;
|
||||
isolation: "transitional-in-process";
|
||||
};
|
||||
permissions: readonly string[];
|
||||
actions: readonly DevicePluginActionDefinition[];
|
||||
models: readonly DeviceModelDefinition[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DevicePluginHostActions {
|
||||
openSpatialScene: () => void;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
model: DeviceModelDefinition;
|
||||
host: DevicePluginHostActions;
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
manifest: DevicePluginManifest;
|
||||
RuntimeProvider: ComponentType<{
|
||||
activeModel: DeviceModelDefinition | null;
|
||||
registerDeactivation: (handler: () => Promise<boolean>) => () => void;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
connectionViews: Readonly<Record<string, ComponentType<DevicePluginConnectionProps>>>;
|
||||
}
|
||||
|
||||
export interface RegisteredDeviceModel {
|
||||
plugin: DeviceUiPlugin;
|
||||
model: DeviceModelDefinition;
|
||||
ConnectionView: ComponentType<DevicePluginConnectionProps>;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION,
|
||||
type DeviceCapability,
|
||||
type DeviceModelDefinition,
|
||||
type DevicePluginActionDefinition,
|
||||
type DevicePluginManifest,
|
||||
} from "./contracts";
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть объектом.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
path: string,
|
||||
allowed: readonly string[],
|
||||
): void {
|
||||
const extras = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||
if (extras.length) {
|
||||
throw new Error(`Некорректный manifest: ${path} содержит неизвестные поля ${extras.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, path: string, maxLength = 160): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть непустой строкой.`);
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new Error(
|
||||
`Некорректный manifest: ${path} длиннее ${maxLength} символов.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function flag(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function list(value: unknown, path: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Некорректный manifest: ${path} должен быть массивом.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function capability(value: unknown, path: string): DeviceCapability {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "label"]);
|
||||
return { id: text(item.id, `${path}.id`), label: text(item.label, `${path}.label`) };
|
||||
}
|
||||
|
||||
function action(value: unknown, path: string): DevicePluginActionDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, ["id", "mutating", "secretFields"]);
|
||||
return {
|
||||
id: text(item.id, `${path}.id`),
|
||||
mutating: flag(item.mutating, `${path}.mutating`),
|
||||
secretFields: list(item.secretFields, `${path}.secretFields`).map((field, index) =>
|
||||
text(field, `${path}.secretFields[${index}]`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function model(value: unknown, path: string): DeviceModelDefinition {
|
||||
const item = record(value, path);
|
||||
exactKeys(item, path, [
|
||||
"id",
|
||||
"vendor",
|
||||
"displayName",
|
||||
"category",
|
||||
"description",
|
||||
"verified",
|
||||
"capabilities",
|
||||
"ui",
|
||||
]);
|
||||
const ui = record(item.ui, `${path}.ui`);
|
||||
exactKeys(ui, `${path}.ui`, ["slot", "componentKey"]);
|
||||
const slot = text(ui.slot, `${path}.ui.slot`);
|
||||
if (slot !== "device.connection") {
|
||||
throw new Error(`Некорректный manifest: слот ${slot} пока не поддерживается.`);
|
||||
}
|
||||
return {
|
||||
id: text(item.id, `${path}.id`),
|
||||
vendor: text(item.vendor, `${path}.vendor`),
|
||||
displayName: text(item.displayName, `${path}.displayName`),
|
||||
category: text(item.category, `${path}.category`),
|
||||
description: text(item.description, `${path}.description`, 1024),
|
||||
verified: flag(item.verified, `${path}.verified`),
|
||||
capabilities: list(item.capabilities, `${path}.capabilities`).map((entry, index) =>
|
||||
capability(entry, `${path}.capabilities[${index}]`),
|
||||
),
|
||||
ui: {
|
||||
slot,
|
||||
componentKey: text(ui.componentKey, `${path}.ui.componentKey`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDevicePluginManifest(document: unknown): DevicePluginManifest {
|
||||
const root = record(document, "root");
|
||||
exactKeys(root, "root", ["apiVersion", "kind", "metadata", "spec"]);
|
||||
if (root.apiVersion !== DEVICE_PLUGIN_API_VERSION || root.kind !== "DevicePlugin") {
|
||||
throw new Error("Manifest использует несовместимую версию или kind.");
|
||||
}
|
||||
const metadata = record(root.metadata, "metadata");
|
||||
exactKeys(metadata, "metadata", ["id", "version", "displayName"]);
|
||||
const spec = record(root.spec, "spec");
|
||||
exactKeys(spec, "spec", [
|
||||
"hostApiRange",
|
||||
"runtime",
|
||||
"permissions",
|
||||
"actions",
|
||||
"models",
|
||||
]);
|
||||
if (spec.hostApiRange !== "v1alpha1") {
|
||||
throw new Error(`Manifest требует несовместимый host API: ${String(spec.hostApiRange)}.`);
|
||||
}
|
||||
const runtime = record(spec.runtime, "spec.runtime");
|
||||
exactKeys(runtime, "spec.runtime", ["backendEntrypoint", "isolation"]);
|
||||
const isolation = text(runtime.isolation, "spec.runtime.isolation");
|
||||
if (isolation !== "transitional-in-process") {
|
||||
throw new Error(`Некорректный manifest: неизвестная изоляция ${isolation}.`);
|
||||
}
|
||||
|
||||
const models = list(spec.models, "spec.models").map((entry, index) =>
|
||||
model(entry, `spec.models[${index}]`),
|
||||
);
|
||||
if (models.length !== 1) {
|
||||
throw new Error("Manifest v1alpha1 должен объявлять ровно одну модель.");
|
||||
}
|
||||
|
||||
const version = text(metadata.version, "metadata.version");
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
||||
throw new Error(`Некорректный manifest: версия ${version} не является semver.`);
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion: DEVICE_PLUGIN_API_VERSION,
|
||||
kind: "DevicePlugin",
|
||||
metadata: {
|
||||
id: text(metadata.id, "metadata.id"),
|
||||
version,
|
||||
displayName: text(metadata.displayName, "metadata.displayName"),
|
||||
},
|
||||
spec: {
|
||||
hostApiRange: "v1alpha1",
|
||||
runtime: {
|
||||
backendEntrypoint: text(
|
||||
runtime.backendEntrypoint,
|
||||
"spec.runtime.backendEntrypoint",
|
||||
256,
|
||||
),
|
||||
isolation,
|
||||
},
|
||||
permissions: list(spec.permissions, "spec.permissions").map((entry, index) =>
|
||||
text(entry, `spec.permissions[${index}]`),
|
||||
),
|
||||
actions: list(spec.actions, "spec.actions").map((entry, index) =>
|
||||
action(entry, `spec.actions[${index}]`),
|
||||
),
|
||||
models,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function requirePluginAction(
|
||||
manifest: DevicePluginManifest,
|
||||
actionId: string,
|
||||
): string {
|
||||
const action = manifest.spec.actions.find((candidate) => candidate.id === actionId);
|
||||
if (!action) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} не объявляет действие ${actionId}.`);
|
||||
}
|
||||
return action.id;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
DEVICE_PLUGIN_API_VERSION,
|
||||
DEVICE_STATE_READ_ACTION_ID,
|
||||
type DeviceUiPlugin,
|
||||
type RegisteredDeviceModel,
|
||||
} from "./contracts";
|
||||
|
||||
export interface DevicePluginRegistry {
|
||||
readonly plugins: readonly DeviceUiPlugin[];
|
||||
readonly models: readonly RegisteredDeviceModel[];
|
||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||
}
|
||||
|
||||
export function createDevicePluginRegistry(
|
||||
installedPlugins: readonly DeviceUiPlugin[],
|
||||
): DevicePluginRegistry {
|
||||
const pluginIds = new Set<string>();
|
||||
const modelIds = new Set<string>();
|
||||
const models: RegisteredDeviceModel[] = [];
|
||||
|
||||
for (const plugin of installedPlugins) {
|
||||
const { manifest } = plugin;
|
||||
if (manifest.apiVersion !== DEVICE_PLUGIN_API_VERSION) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} использует несовместимый контракт ${manifest.apiVersion}.`,
|
||||
);
|
||||
}
|
||||
if (manifest.kind !== "DevicePlugin") {
|
||||
throw new Error(`Неподдерживаемый kind плагина: ${manifest.kind}.`);
|
||||
}
|
||||
if (!manifest.metadata.id.trim() || pluginIds.has(manifest.metadata.id)) {
|
||||
throw new Error(
|
||||
`Идентификатор плагина пуст или повторяется: ${manifest.metadata.id || "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
pluginIds.add(manifest.metadata.id);
|
||||
|
||||
if (manifest.spec.models.length !== 1) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять ровно одну модель в v1alpha1.`,
|
||||
);
|
||||
}
|
||||
|
||||
const permissions = new Set(manifest.spec.permissions);
|
||||
if (permissions.size !== manifest.spec.permissions.length) {
|
||||
throw new Error(`Плагин ${manifest.metadata.id} повторяет permission.`);
|
||||
}
|
||||
|
||||
const actionIds = new Set<string>();
|
||||
for (const action of manifest.spec.actions) {
|
||||
if (!action.id.trim() || actionIds.has(action.id)) {
|
||||
throw new Error(
|
||||
`Идентификатор действия пуст или повторяется: ${action.id || "<empty>"}.`,
|
||||
);
|
||||
}
|
||||
actionIds.add(action.id);
|
||||
if (new Set(action.secretFields).size !== action.secretFields.length) {
|
||||
throw new Error(`Действие ${action.id} повторяет secret field.`);
|
||||
}
|
||||
}
|
||||
const stateRead = manifest.spec.actions.find(
|
||||
(action) => action.id === DEVICE_STATE_READ_ACTION_ID,
|
||||
);
|
||||
if (!stateRead || stateRead.mutating || stateRead.secretFields.length) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} должен объявлять безопасное действие state.read.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const model of manifest.spec.models) {
|
||||
if (!model.id.trim() || modelIds.has(model.id)) {
|
||||
throw new Error(`Идентификатор модели пуст или повторяется: ${model.id || "<empty>"}.`);
|
||||
}
|
||||
modelIds.add(model.id);
|
||||
const capabilityIds = new Set(model.capabilities.map((capability) => capability.id));
|
||||
if (capabilityIds.size !== model.capabilities.length) {
|
||||
throw new Error(`Модель ${model.id} повторяет capability.`);
|
||||
}
|
||||
const ConnectionView = plugin.connectionViews[model.ui.componentKey];
|
||||
if (!ConnectionView) {
|
||||
throw new Error(
|
||||
`Плагин ${manifest.metadata.id} не реализует UI ${model.ui.componentKey}.`,
|
||||
);
|
||||
}
|
||||
models.push({ plugin, model, ConnectionView });
|
||||
}
|
||||
}
|
||||
|
||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||
return {
|
||||
plugins: Object.freeze([...installedPlugins]),
|
||||
models: Object.freeze(models),
|
||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
import type { MissionRuntimeController } from "./contracts";
|
||||
|
||||
const idleRuntime: MissionRuntimeController = {
|
||||
state: {
|
||||
phase: "unconfigured",
|
||||
message: "Сначала выберите модель локального устройства.",
|
||||
sourceMode: "idle",
|
||||
},
|
||||
backendStatus: "unconfigured",
|
||||
pendingAction: null,
|
||||
refresh: () => undefined,
|
||||
updateViewerSettings: async () => false,
|
||||
};
|
||||
|
||||
const MissionRuntimeContext = createContext<MissionRuntimeController>(idleRuntime);
|
||||
|
||||
export function MissionRuntimeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: MissionRuntimeController;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <MissionRuntimeContext.Provider value={value}>{children}</MissionRuntimeContext.Provider>;
|
||||
}
|
||||
|
||||
export function IdleMissionRuntimeProvider({ children }: { children: ReactNode }) {
|
||||
return <MissionRuntimeProvider value={idleRuntime}>{children}</MissionRuntimeProvider>;
|
||||
}
|
||||
|
||||
export function useMissionRuntime(): MissionRuntimeController {
|
||||
return useContext(MissionRuntimeContext);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export type BackendStatus = "unconfigured" | "checking" | "online" | "degraded" | "offline";
|
||||
|
||||
export type RuntimePhase =
|
||||
| "unconfigured"
|
||||
| "idle"
|
||||
| "configuring"
|
||||
| "connected"
|
||||
| "starting"
|
||||
| "streaming"
|
||||
| "replaying"
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type SourceMode = "idle" | "live" | "replay";
|
||||
|
||||
export interface ViewerSettings {
|
||||
point_size: number;
|
||||
color_mode: "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
palette: "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||
custom_color: string;
|
||||
accumulation_seconds: number;
|
||||
show_points: boolean;
|
||||
show_trajectory: boolean;
|
||||
show_grid: boolean;
|
||||
}
|
||||
|
||||
export interface StreamMetrics {
|
||||
latencyMs?: number | null;
|
||||
frameRateHz?: number | null;
|
||||
pointCount?: number | null;
|
||||
droppedPreviewFrames?: number | null;
|
||||
}
|
||||
|
||||
export interface ActiveDeviceSnapshot {
|
||||
pluginId: string;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
instanceId?: string | null;
|
||||
endpointLabel?: string | null;
|
||||
}
|
||||
|
||||
export interface SpatialSourceDescriptor {
|
||||
id: string;
|
||||
url: string;
|
||||
label: string;
|
||||
kind: "rerun-grpc" | "rrd" | "other";
|
||||
}
|
||||
|
||||
export interface MissionRuntimeState {
|
||||
phase: RuntimePhase;
|
||||
message?: string | null;
|
||||
activeDevice?: ActiveDeviceSnapshot | null;
|
||||
spatialSource?: SpatialSourceDescriptor | null;
|
||||
viewerSettings?: ViewerSettings | null;
|
||||
sourceMode: SourceMode;
|
||||
metrics?: StreamMetrics;
|
||||
}
|
||||
|
||||
export interface MissionRuntimeController {
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
pendingAction: string | null;
|
||||
refresh: () => void | Promise<void>;
|
||||
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user