import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from "react"; import { Button, GlassSurface, Icon, IconButton, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions, } from "@nodedc/ui-react"; import { createConfigurationRevision, closeAssetBinding, ensureAsset, ensureAssetBinding, ensureAdapterPackage, ensureDeviceBinding, ensureEdge, ensureInfrastructureDeployment, ensureInfrastructureEndpoint, ensureInfrastructureHost, ensureInfrastructureServiceInstance, ensureRoute, registerAdapterVersion, registerModelProfile, recordHealthObservation, revokeDeviceBinding, sendServicePing, setDesiredConfiguration, upsertProjectGrant, } from "./api"; import type { AdapterPackageView, AdapterVersionView, AssetBindingView, BindingView, DeviceManagerSession, EdgeView, InfrastructureHostView, InfrastructureServiceInstanceView, ModelProfileView, ProjectWorkspace, } from "./types"; export type ControlViewId = | "catalog" | "infrastructure" | "hosts" | "sessions" | "bindings" | "commands" | "audit" | "access" | "settings"; type DialogId = | "adapter-package" | "adapter-version" | "model-profile" | "edge" | "route" | "binding" | "grant" | "configuration" | "asset" | "asset-binding" | "host" | "endpoint" | "deployment" | "service-instance" | "health-observation" | null; export function DeviceControlView({ view, workspace, session, onRefresh, onPoll, onError, }: { view: ControlViewId; workspace: ProjectWorkspace; session: DeviceManagerSession; onRefresh: () => Promise; onPoll: () => Promise; onError: (reason: unknown) => void; }) { const [dialog, setDialog] = useState(null); const capabilities = new Set(workspace.project.access.capabilities); const platformOwner = session.actor.hubRole === "owner"; const close = () => setDialog(null); const completed = async () => { close(); await onRefresh(); }; const mutateAndRefresh = async (mutation: () => Promise) => { try { await mutation(); await onRefresh(); } catch (reason) { onError(reason); } }; return ( <> {view === "catalog" ? ( setDialog("adapter-package")} onCreateVersion={() => setDialog("adapter-version")} onCreateProfile={() => setDialog("model-profile")} onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({ adapterPackageRef: version.adapterPackageRef, version: version.version, runtimePackageRef: version.runtimePackageRef, contentDigest: version.contentDigest, contractVersion: version.contractVersion, capabilities: version.capabilities, lifecycleState: "active", }))} onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({ adapterVersionRef: profile.adapterVersionRef || "", profileRef: profile.modelProfileRef, schemaVersion: profile.schemaVersion, vendor: profile.vendor, model: profile.model, deviceType: profile.deviceType, protocol: profile.protocol, schemaArtifactRef: profile.schemaArtifactRef || "", profileDigest: profile.profileDigest || "", capabilities: profile.capabilities, lifecycleState: "active", }))} /> ) : null} {view === "infrastructure" ? ( setDialog("edge")} onCreateRoute={() => setDialog("route")} onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({ edgeKey: edge.edgeKey, displayName: edge.displayName, deploymentRef: edge.deploymentRef, lifecycleState: "active", }))} onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({ projectRef: workspace.project.projectRef, routeKey: route.routeKey, displayName: route.displayName, edgeRef: route.edgeRef, modelProfileRef: route.modelProfileRef, listenerRef: route.listenerRef, protocol: route.protocol, direction: route.direction, lifecycleState: "active", }))} /> ) : null} {view === "hosts" ? ( setDialog("host")} onCreateEndpoint={() => setDialog("endpoint")} onCreateDeployment={() => setDialog("deployment")} onCreateService={() => setDialog("service-instance")} onRecordHealth={() => setDialog("health-observation")} onCreateAsset={() => setDialog("asset")} onCreateAssetBinding={() => setDialog("asset-binding")} onCloseAssetBinding={(binding) => mutateAndRefresh(() => closeAssetBinding({ projectRef: workspace.project.projectRef, assetBindingRef: binding.assetBindingRef, validTo: new Date().toISOString(), }))} onPoll={onPoll} onError={onError} /> ) : null} {view === "sessions" ? : null} {view === "bindings" ? ( setDialog("binding")} onRevoke={(binding) => revokeDeviceBinding({ projectRef: workspace.project.projectRef, bindingRef: binding.bindingRef, resolutionCode: "operator.revoked", }).then(onRefresh).catch(onError)} /> ) : null} {view === "commands" ? ( ) : null} {view === "audit" ? : null} {view === "access" ? ( setDialog("grant")} /> ) : null} {view === "settings" ? ( setDialog("configuration")} /> ) : null} ); } function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: { workspace: ProjectWorkspace; canManage: boolean; onCreatePackage: () => void; onCreateVersion: () => void; onCreateProfile: () => void; onActivateVersion: (version: AdapterVersionView) => void; onActivateProfile: (profile: ModelProfileView) => void; }) { return ( : null} /> {workspace.modelProfiles.map((profile) => ( onActivateProfile(profile)}>Активировать ) : null} /> ))} {workspace.adapterVersions.map((version) => ( onActivateVersion(version)}>Активировать ) : null} /> ))} {workspace.adapterPackages.map((adapterPackage) => ( version.adapterPackageRef === adapterPackage.adapterPackageRef) .map((version) => `${version.version} · ${version.lifecycleState}`)} /> ))} ); } function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: { workspace: ProjectWorkspace; canManageCatalog: boolean; canManageRoutes: boolean; onCreateEdge: () => void; onCreateRoute: () => void; onActivateEdge: (edge: EdgeView) => void; onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void; }) { return ( {canManageCatalog ? : null} {canManageRoutes ? : null} } /> {workspace.routes.map((route) => ( onActivateRoute(route)}>Активировать ) : null} /> ))} {workspace.edges.map((edge) => ( onActivateEdge(edge)}>Активировать ) : null} /> ))} ); } function HostsView({ workspace, canManageInfrastructure, canManageAssets, canManageBindings, onCreateHost, onCreateEndpoint, onCreateDeployment, onCreateService, onRecordHealth, onCreateAsset, onCreateAssetBinding, onCloseAssetBinding, onPoll, onError, }: { workspace: ProjectWorkspace; canManageInfrastructure: boolean; canManageAssets: boolean; canManageBindings: boolean; onCreateHost: () => void; onCreateEndpoint: () => void; onCreateDeployment: () => void; onCreateService: () => void; onRecordHealth: () => void; onCreateAsset: () => void; onCreateAssetBinding: () => void; onCloseAssetBinding: (binding: AssetBindingView) => void; onPoll: () => Promise; onError: (reason: unknown) => void; }) { const [selectedHostRef, setSelectedHostRef] = useState(null); const [expandedHostRefs, setExpandedHostRefs] = useState>(() => new Set()); const inventoryRef = useRef(null); const topology = workspace.ontology; const selectedHost = selectedHostRef ? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null : null; useEffect(() => { if (selectedHostRef) return; resetApplicationPanelScroll(inventoryRef.current); }, [selectedHostRef]); const toggleHost = (hostRef: string) => { setExpandedHostRefs((current) => { const next = new Set(current); if (next.has(hostRef)) next.delete(hostRef); else next.add(hostRef); return next; }); }; if (selectedHost) { return ( service.hostRef === selectedHost.hostRef)} onBack={() => setSelectedHostRef(null)} onPoll={onPoll} onError={onError} /> ); } return (
ИНФРАСТРУКТУРА / VPS И ХОСТЫ

VPS и хосты

Вычислительные узлы проекта, их подключения и запущенные сервисы.

{canManageInfrastructure ? (
) : null}
host.telemetry.freshness === "fresh").length} со свежими данными`} />
: null} /> {topology.hosts.length ? (
{topology.hosts.map((host) => { const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef); const hostDeployments = topology.deployments.filter((item) => item.hostRef === host.hostRef); const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef); const expanded = expandedHostRefs.has(host.hostRef); const detailsId = `infrastructure-host-details-${host.hostRef.replace(/[^A-Za-z0-9_-]/g, "-")}`; return (
COMPUTE HOST

{host.displayName}

{host.externalRef || host.hostKey}
toggleHost(host.hostRef)} >
{expanded ? (
Состояние
{host.lifecycleState}
Провайдер
{host.providerRef || "Не указан"}
Доступ управления
{host.managementCredentialConfigured ? "Настроен" : "Не настроен"}
Последнее наблюдение
{formatDate(host.telemetry.observedAt)}
ENDPOINTS{hostEndpoints.length}
{hostEndpoints.length ?
{hostEndpoints.map((endpoint) => ( ))}
:

Точки подключения не зарегистрированы.

}
DEPLOYMENTS{hostDeployments.length}
{hostDeployments.length ?
{hostDeployments.map((deployment) => ( ))}
:

Развёртывания не зарегистрированы.

}
SERVICES{hostServices.length}
{hostServices.length ?
{hostServices.map((service) => { const edge = service.edgeRef ? workspace.edges.find((item) => item.edgeRef === service.edgeRef) : null; const runtimeState = edge?.channel.runtimeState || service.health.state; return ; })}
:

Сервисы не зарегистрированы.

}
) : null}
); })}
) :
VPS и хосты для проекта пока не зарегистрированы.
}
{canManageAssets ? : null} {canManageBindings ? : null} } /> {topology.assets.length ? (
{topology.assets.map((asset) => { const activeBindings = topology.assetBindings.filter((binding) => binding.assetRef === asset.assetRef && !binding.validTo); return (
{asset.assetTypeRef}{asset.displayName}{asset.assetKey}
{asset.lifecycleState}{activeBindings.length} активных привязок
); })}
) :
Объекты проекта пока не созданы.
} {topology.assetBindings.length ? (
{topology.assetBindings.map((binding) => (
DEVICE ↔ ASSET{binding.deviceName} → {binding.assetName}{binding.bindingKind} · {formatDate(binding.validFrom)}
{binding.validTo ? "Закрыта" : "Активна"}{!binding.validTo && canManageBindings ? : null}
))}
) : null}
); } function HostTelemetryWorkspace({ host, services, onBack, onPoll, onError, }: { host: InfrastructureHostView; services: InfrastructureServiceInstanceView[]; onBack: () => void; onPoll: () => Promise; onError: (reason: unknown) => void; }) { const workspaceRef = useRef(null); useEffect(() => { resetApplicationPanelScroll(workspaceRef.current); }, [host.hostRef]); useEffect(() => { let active = true; const timer = window.setInterval(() => { onPoll().catch((reason) => active && onError(reason)); }, 3_000); return () => { active = false; window.clearInterval(timer); }; }, [onError, onPoll]); const telemetry = host.telemetry; const current = telemetry.current; const networkRate = calculateNetworkRate(telemetry.history); const cpuHistory = telemetry.history.map((sample) => sample.cpuUsagePercent); const memoryHistory = telemetry.history.map((sample) => sample.memoryUsedPercent); const receiveHistory = calculateNetworkRateHistory(telemetry.history, "received"); const sendHistory = calculateNetworkRateHistory(telemetry.history, "sent"); const cpuDomain = percentageTelemetryWindow(cpuHistory, 5); const memoryDomain = percentageTelemetryWindow(memoryHistory, 4); const runtimeServices = current?.services ?? []; const networkInterfaces = current?.network.filter((item) => item.interface !== "lo") ?? []; return (
СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ

{host.displayName}

Аппаратный и процессинговый срез выбранного VPS. Последнее обновление: {formatDate(telemetry.observedAt)}.

{freshnessLabel(telemetry.freshness)} onPoll().catch(onError)}>
{(current?.disks ?? []).map((disk, index) => (
Диск {disk.mount ?? disk.device ?? "—"} {formatUsedTotal(disk.usedBytes, disk.totalBytes)}
))} {!current?.disks.length ?
Данные о дисках ещё не поступили.
: null}
{networkInterfaces.length ? (
{networkInterfaces.map((item, index) => (
{item.interface ?? "Интерфейс"}{formatPackets(item.packetsReceived, item.packetsSent)}
Получено
{formatMetricBytes(item.bytesReceived)}
Отправлено
{formatMetricBytes(item.bytesSent)}
Ошибки RX / TX
{formatNullable(item.errorsReceived)} / {formatNullable(item.errorsSent)}
Потери RX / TX
{formatNullable(item.droppedReceived)} / {formatNullable(item.droppedSent)}
))}
) :
Сетевые счётчики ещё не поступили.
}
service.activeState === "failed") ? "danger" : runtimeServices.length ? "success" : "warning"} /> {runtimeServices.length ? (
{runtimeServices.map((service, index) => (
SYSTEMD UNIT

{service.name ?? "systemd unit"}

{service.subState ?? "—"}
{service.activeState ?? "unknown"}
Load
{service.loadState ?? "—"}
Память
{formatMetricBytes(service.memoryBytes)}
Перезапуски
{formatNullable(service.restarts)}
PID
{formatNullable(service.pid)}
))}
) :
Состояние сервисов ещё не поступило.
}
); } function InfrastructureCount({ label, value, detail }: { label: string; value: number; detail: string }) { return (
{label}{value}{detail}
); } function InfrastructureSectionHeading({ eyebrow, title, description, status, statusTone: tone = "neutral", actions = null }: { eyebrow: string; title: string; description: string; status: string; statusTone?: "neutral" | "success" | "warning" | "danger"; actions?: ReactNode; }) { return (
{eyebrow}

{title}

{description}

{status}{actions}
); } function InfrastructureRegistryRow({ label, title, description, status }: { label: string; title: string; description: string; status: string }) { return (
{label}{title}{description}
{status}
); } type TelemetryLineDomain = { minimum: number; maximum: number; label?: string; }; function HostTelemetrySeries({ label, values, value, resource, domain }: { label: string; values: Array; value: string; resource?: string | null; domain?: TelemetryLineDomain | null }) { const points = telemetryLinePoints(values, domain); return (
{label}{resource ? {resource} : null} {value}
{points ? : null} {domain?.label ? {domain.label} : null}
); } function telemetryLinePoints(values: Array, domain?: TelemetryLineDomain | null) { const finite = values.filter((value): value is number => value !== null && Number.isFinite(value)); if (!finite.length) return ""; const minimum = domain?.minimum ?? 0; const maximum = Math.max(domain?.maximum ?? Math.max(...finite, 1), minimum + Number.EPSILON); const range = maximum - minimum; const denominator = Math.max(1, values.length - 1); return values.flatMap((value, index) => { if (value === null || !Number.isFinite(value)) return []; const x = index / denominator * 100; const normalized = Math.max(minimum, Math.min(maximum, value)); const y = 36 - (normalized - minimum) / range * 34; return [`${x.toFixed(2)},${y.toFixed(2)}`]; }).join(" "); } function percentageTelemetryWindow(values: Array, minimumSpan: number): TelemetryLineDomain | null { const finite = values .filter((value): value is number => value !== null && Number.isFinite(value)) .map((value) => Math.max(0, Math.min(100, value))); if (!finite.length) return null; const observedMinimum = Math.min(...finite); const observedMaximum = Math.max(...finite); const padding = Math.max(0.5, (observedMaximum - observedMinimum) * 0.15); let minimum = observedMinimum - padding; let maximum = observedMaximum + padding; if (maximum - minimum < minimumSpan) { const center = (observedMinimum + observedMaximum) / 2; minimum = center - minimumSpan / 2; maximum = center + minimumSpan / 2; } if (minimum < 0) { maximum = Math.min(100, maximum - minimum); minimum = 0; } if (maximum > 100) { minimum = Math.max(0, minimum - (maximum - 100)); maximum = 100; } minimum = Math.floor(minimum * 10) / 10; maximum = Math.ceil(maximum * 10) / 10; return { minimum, maximum, label: `шкала ${formatScalePercent(minimum)}–${formatScalePercent(maximum)}`, }; } function formatScalePercent(value: number) { return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}%`; } function TelemetryFact({ label, value }: { label: string; value: string | null | undefined }) { return
{label}
{value || "—"}
; } function freshnessTone(freshness: "fresh" | "stale" | "missing"): "success" | "warning" | "danger" { if (freshness === "fresh") return "success"; if (freshness === "stale") return "warning"; return "danger"; } function freshnessLabel(freshness: "fresh" | "stale" | "missing") { if (freshness === "fresh") return "Свежие данные"; if (freshness === "stale") return "Данные устарели"; return "Нет данных"; } function russianCount(value: number, one: string, few: string, many: string) { const absolute = Math.abs(value) % 100; const last = absolute % 10; const form = absolute > 10 && absolute < 20 ? many : last === 1 ? one : last > 1 && last < 5 ? few : many; return `${new Intl.NumberFormat("ru-RU").format(value)} ${form}`; } function resetApplicationPanelScroll(element: HTMLElement | null) { const scroller = element?.closest(".nodedc-application-panel__body"); if (scroller) scroller.scrollTop = 0; } function calculateNetworkRate(history: InfrastructureHostView["telemetry"]["history"]) { if (history.length < 2) return { received: null, sent: null }; const previous = history[history.length - 2]; const latest = history[history.length - 1]; const seconds = (new Date(latest.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000; if (!Number.isFinite(seconds) || seconds <= 0) return { received: null, sent: null }; const previousTotals = networkTotals(previous.network); const latestTotals = networkTotals(latest.network); if (!previousTotals || !latestTotals) return { received: null, sent: null }; return { received: latestTotals.received == null || previousTotals.received == null ? null : nonNegativeRate(latestTotals.received - previousTotals.received, seconds), sent: latestTotals.sent == null || previousTotals.sent == null ? null : nonNegativeRate(latestTotals.sent - previousTotals.sent, seconds), }; } function calculateNetworkRateHistory(history: InfrastructureHostView["telemetry"]["history"], direction: "received" | "sent") { return history.slice(1).map((sample, index) => { const previous = history[index]; const seconds = (new Date(sample.observedAt).valueOf() - new Date(previous.observedAt).valueOf()) / 1000; if (seconds <= 0) return null; const currentTotals = networkTotals(sample.network); const previousTotals = networkTotals(previous.network); if (!currentTotals || !previousTotals || currentTotals[direction] == null || previousTotals[direction] == null) return null; return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds); }); } function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) { const counters = network.filter((item) => item.interface !== "lo" && (item.bytesReceived != null || item.bytesSent != null)); if (!counters.length) return null; const receivedCounters = counters.map((item) => item.bytesReceived).filter((value): value is number => value !== null && Number.isFinite(value)); const sentCounters = counters.map((item) => item.bytesSent).filter((value): value is number => value !== null && Number.isFinite(value)); return { received: receivedCounters.length ? receivedCounters.reduce((total, value) => total + value, 0) : null, sent: sentCounters.length ? sentCounters.reduce((total, value) => total + value, 0) : null, }; } function nonNegativeRate(bytes: number, seconds: number) { const value = bytes / seconds; return Number.isFinite(value) && value >= 0 ? value : null; } function formatPercent(value: number | null | undefined) { return value == null ? "—" : `${value.toFixed(value >= 10 ? 0 : 1)}%`; } function formatRate(value: number | null) { return value == null ? "—" : `${formatMetricBytes(value)}/s`; } function formatMetricBytes(value: number | null | undefined) { if (value == null || !Number.isFinite(value)) return "—"; if (value < 1024) return `${Math.round(value)} B`; if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KiB`; if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MiB`; return `${(value / 1024 ** 3).toFixed(1)} GiB`; } function formatUsedTotal(used: number | null | undefined, total: number | null | undefined) { return used == null || total == null ? "—" : `${formatMetricBytes(used)} / ${formatMetricBytes(total)}`; } function formatNullable(value: number | null | undefined) { return value == null ? "—" : new Intl.NumberFormat("ru-RU").format(value); } function formatDuration(seconds: number | null | undefined) { if (seconds == null) return "—"; const days = Math.floor(seconds / 86_400); const hours = Math.floor((seconds % 86_400) / 3_600); const minutes = Math.floor((seconds % 3_600) / 60); return [days ? `${days} д` : null, hours ? `${hours} ч` : null, `${minutes} мин`].filter(Boolean).join(" "); } function formatLoad(cpu: { load1: number | null; load5: number | null; load15: number | null } | null | undefined) { if (!cpu || cpu.load1 == null) return "load average —"; return `load ${[cpu.load1, cpu.load5, cpu.load15].map((value) => value?.toFixed(2) ?? "—").join(" / ")}`; } function formatPackets(received: number | null | undefined, sent: number | null | undefined) { return `↓ ${formatNullable(received)} пакетов · ↑ ${formatNullable(sent)} пакетов`; } function SessionsView({ workspace }: { workspace: ProjectWorkspace }) { return ( {workspace.sessions.map((session) => ( ))} ); } function BindingsView({ workspace, canManage, onCreate, onRevoke }: { workspace: ProjectWorkspace; canManage: boolean; onCreate: () => void; onRevoke: (binding: BindingView) => void; }) { return ( Новый binding : null} /> {workspace.bindings.map((binding) => ( onRevoke(binding)}>Отозвать ) : binding.capabilities.join(", ")} /> ))} ); } function CommandsView({ workspace, canDispatch, onRefresh, onError }: { workspace: ProjectWorkspace; canDispatch: boolean; onRefresh: () => Promise; onError: (reason: unknown) => void; }) { const supportedDevices = workspace.devices.filter( (device) => device.modelProfileRef === "arusnavi.b2.internal.v1" && !["suspended", "retired"].includes(device.lifecycleState), ); const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? ""); const [accessCode, setAccessCode] = useState(""); const [submitting, setSubmitting] = useState(false); const enabled = workspace.policies.commandTransport === "typed-service-ping-v1"; useEffect(() => { if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) { setDeviceRef(supportedDevices[0]?.deviceRef ?? ""); } }, [deviceRef, supportedDevices]); const submit = async (event: FormEvent) => { event.preventDefault(); if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return; setSubmitting(true); try { await sendServicePing({ projectRef: workspace.project.projectRef, deviceRef, accessCode, expiresInSeconds: 300, }); setAccessCode(""); await onRefresh(); } catch (reason) { onError(reason); } finally { setSubmitting(false); } }; return (
{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}

{enabled ? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL." : "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}

{workspace.policies.commandTransport}
{enabled ? (
({ value: item.adapterPackageRef, label: item.displayName }))} /> setVersion(event.target.value)} required placeholder="1.0.0" /> setRuntimeRef(event.target.value)} required /> setDigest(event.target.value)} required placeholder="sha256:…" /> setContractVersion(event.target.value)} required /> setCapabilities(event.target.value)} description="Через запятую" /> ; } function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions: AdapterVersionView[] }) { const [versionRef, setVersionRef] = useState(versions[0]?.adapterVersionRef ?? ""); const [profileRef, setProfileRef] = useState(""); const [schemaVersion, setSchemaVersion] = useState(""); const [vendor, setVendor] = useState(""); const [model, setModel] = useState(""); const [deviceType, setDeviceType] = useState(""); const [protocol, setProtocol] = useState(""); const [schemaRef, setSchemaRef] = useState(""); const [digest, setDigest] = useState(""); const [capabilities, setCapabilities] = useState(""); useEffect(() => { if (!versions.some((item) => item.adapterVersionRef === versionRef)) { setVersionRef(versions[0]?.adapterVersionRef ?? ""); } }, [versionRef, versions]); return { await registerModelProfile({ adapterVersionRef: versionRef, profileRef, schemaVersion, vendor, model, deviceType, protocol: protocol.toUpperCase(), schemaArtifactRef: schemaRef, profileDigest: digest, capabilities: commaList(capabilities), lifecycleState: "draft", }); }}> ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} /> setProvenanceRef(event.target.value)} required /> ; } function HostDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) { const [hostKey, setHostKey] = useState(""); const [displayName, setDisplayName] = useState(""); const [providerRef, setProviderRef] = useState(""); const [externalRef, setExternalRef] = useState(""); const [credentialRef, setCredentialRef] = useState(""); return { await ensureInfrastructureHost({ projectRef, hostKey, displayName, providerRef: providerRef || null, externalRef: externalRef || null, managementCredentialRef: credentialRef || null, lifecycleState: "active", }); }}> setDisplayName(event.target.value)} required /> setProviderRef(event.target.value)} placeholder="provider:beget" /> setExternalRef(event.target.value)} placeholder="provider-resource:vps-123" /> setCredentialRef(event.target.value)} placeholder="secret-ref:device-core/host-key" description="Только server-side secret reference. Пароль или приватный ключ сюда вводить нельзя." /> ; } function EndpointDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) { const hosts = workspace.ontology.hosts; const [hostRef, setHostRef] = useState(hosts[0]?.hostRef ?? ""); const [endpointKey, setEndpointKey] = useState(""); const [purpose, setPurpose] = useState<"management" | "service" | "monitoring">("management"); const [endpointUri, setEndpointUri] = useState(""); useEffect(() => { if (!hosts.some((item) => item.hostRef === hostRef)) setHostRef(hosts[0]?.hostRef ?? ""); }, [hostRef, hosts]); return { await ensureInfrastructureEndpoint({ projectRef: workspace.project.projectRef, hostRef, endpointKey, purpose, endpointUri, lifecycleState: "active", }); }}> setEndpointUri(event.target.value)} required placeholder="ssh://203.0.113.10:22/" description="HTTPS, SSH или TCP. URI с userinfo, query или fragment будет отклонён." /> ; } function DeploymentDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) { const hosts = workspace.ontology.hosts; const [hostRef, setHostRef] = useState(hosts[0]?.hostRef ?? ""); const [deploymentKey, setDeploymentKey] = useState(""); const [displayName, setDisplayName] = useState(""); const [artifactRef, setArtifactRef] = useState(""); const [artifactDigest, setArtifactDigest] = useState(""); useEffect(() => { if (!hosts.some((item) => item.hostRef === hostRef)) setHostRef(hosts[0]?.hostRef ?? ""); }, [hostRef, hosts]); return { await ensureInfrastructureDeployment({ projectRef: workspace.project.projectRef, hostRef, deploymentKey, displayName, artifactRef, artifactDigest, lifecycleState: "active", }); }}> ({ value: item.hostRef, label: item.displayName }))} /> ({ value: item.edgeRef, label: item.displayName, description: item.channel.runtimeState })), ]} /> setDisplayName(event.target.value)} required /> setServiceRole(event.target.value.toLowerCase())} required placeholder="device.edge" /> ; } function HealthObservationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) { const targets = [ ...workspace.ontology.hosts.map((item) => ({ value: `host|${item.hostRef}`, label: item.displayName, description: "Host" })), ...workspace.ontology.serviceInstances.map((item) => ({ value: `service-instance|${item.serviceInstanceRef}`, label: item.displayName, description: "Service instance" })), ]; const [target, setTarget] = useState(targets[0]?.value ?? ""); const [observedState, setObservedState] = useState<"reachable" | "degraded" | "unreachable">("reachable"); const [ttlMinutes, setTtlMinutes] = useState("5"); const [evidence, setEvidence] = useState("{}"); useEffect(() => { if (!targets.some((item) => item.value === target)) setTarget(targets[0]?.value ?? ""); }, [target, targets]); return { const [subjectKind, subjectRef] = target.split("|"); const observedAt = new Date(); const ttl = Number(ttlMinutes); await recordHealthObservation({ projectRef: workspace.project.projectRef, subjectKind: subjectKind as "host" | "service-instance", subjectRef, observedState, evidenceClass: "manual", sourceRef: "device-manager:manual-observation", schemaRef: "nodedc.health.manual.v1", evidence: JSON.parse(evidence) as Record, observedAt: observedAt.toISOString(), expiresAt: new Date(observedAt.valueOf() + ttl * 60_000).toISOString(), }); }}> setTtlMinutes(event.target.value)} required inputMode="numeric" /> setEvidence(event.target.value)} required />

Это ручное наблюдение с TTL. Автоматический probe должен писать тот же canonical contract от собственного source ref.

; } function EdgeDialog(props: DialogBaseProps) { const [edgeKey, setEdgeKey] = useState(""); const [displayName, setDisplayName] = useState(""); const [deploymentRef, setDeploymentRef] = useState(""); return { await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" }); }}> setDisplayName(event.target.value)} required /> setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." /> ; } function RouteDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) { const [routeKey, setRouteKey] = useState(""); const [displayName, setDisplayName] = useState(""); const [edgeRef, setEdgeRef] = useState(workspace.edges[0]?.edgeRef ?? ""); const [profileRef, setProfileRef] = useState(workspace.modelProfiles[0]?.modelProfileRef ?? ""); const [listenerRef, setListenerRef] = useState(""); const profile = workspace.modelProfiles.find((item) => item.modelProfileRef === profileRef); useEffect(() => { if (!workspace.edges.some((item) => item.edgeRef === edgeRef)) { setEdgeRef(workspace.edges[0]?.edgeRef ?? ""); } if (!workspace.modelProfiles.some((item) => item.modelProfileRef === profileRef)) { setProfileRef(workspace.modelProfiles[0]?.modelProfileRef ?? ""); } }, [edgeRef, profileRef, workspace.edges, workspace.modelProfiles]); return { await ensureRoute({ projectRef: workspace.project.projectRef, routeKey, displayName, edgeRef, modelProfileRef: profileRef, listenerRef, protocol: profile?.protocol || "INTERNAL", direction: "telemetry", lifecycleState: "draft", }); }}> setDisplayName(event.target.value)} required /> ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} /> setListenerRef(event.target.value)} required />

Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.

; } function BindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) { const sources = useMemo(() => [ ...workspace.collections.map((item) => ({ value: `collection|${item.collectionRef}`, label: item.name })), ...workspace.devices.map((item) => ({ value: `device|${item.deviceRef}`, label: item.displayName })), ], [workspace]); const [sourceValue, setSourceValue] = useState(sources[0]?.value ?? ""); const [bindingKey, setBindingKey] = useState(""); const [displayName, setDisplayName] = useState(""); const [targetKind, setTargetKind] = useState(""); const [targetRef, setTargetRef] = useState(""); const [capabilities, setCapabilities] = useState("observe"); useEffect(() => { if (!sources.some((item) => item.value === sourceValue)) { setSourceValue(sources[0]?.value ?? ""); } }, [sourceValue, sources]); return { const [kind, ref] = sourceValue.split("|"); await ensureDeviceBinding({ projectRef: workspace.project.projectRef, bindingKey, displayName, source: { kind: kind as "device" | "collection", ref }, targetKind, targetRef, capabilities: commaList(capabilities), }); }}> setPrincipalRef(event.target.value)} required /> ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} /> setConfiguration(event.target.value)} required /> setSummary(event.target.value)} />

Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.

; } interface DialogBaseProps { open: boolean; onClose: () => void; onCreated: () => Promise; onError: (reason: unknown) => void; } function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & { id: string; title: string; submit: () => Promise; disabled?: boolean; children: ReactNode; }) { const [pending, setPending] = useState(false); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setPending(true); try { await submit(); await onCreated(); } catch (reason) { onError(reason); } finally { setPending(false); } }; return ( }> {children} ); } function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />; } function ControlStack({ children }: { children: ReactNode }) { return
{children}
; } function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) { return

{copy}

{actions ?
{actions}
: null}
; } function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) { return

{title}

{count}
{children}
; } function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) { const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children); return hasChildren ?
{children}
:
{empty}
; } function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) { return {status}{action}}> {meta.length ?
{meta.map((item) => {item})}
:

Metadata-only projection

}
; } function ResourceList({ children, empty }: { children: ReactNode; empty: string }) { const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children); return hasChildren ?
{children}
:
{empty}
; } function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) { return {title}{description} {status}{typeof trailing === "string" ? {trailing} : trailing} ; } function PolicyCard({ label, value }: { label: string; value: string }) { return {label}{value}; } function commaList(value: string) { return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort(); } function statusTone(status: string): "neutral" | "success" | "warning" | "danger" { if (["active", "accepted", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success"; if (["absent", "failed", "rejected", "revoked", "retired"].includes(status)) return "danger"; if (["connecting", "draft", "provisioning", "pending", "pending_external_approval", "unknown", "unobserved", "disabled"].includes(status)) return "warning"; return "neutral"; } function formatDate(value: string | null) { if (!value) return "—"; return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); } function formatBytes(value: number) { if (value < 1024) return `${value} B`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`; return `${(value / (1024 * 1024)).toFixed(1)} MiB`; } function shortRef(value: string | null) { return value ? `${value.slice(0, 18)}…` : "—"; } function shortDigest(value: string) { return `${value.slice(0, 15)}…${value.slice(-8)}`; }