Files
NODEDC_DEVICE_CORE/apps/device-manager/src/DeviceControlViews.tsx
T

1697 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, 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<void>;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [dialog, setDialog] = useState<DialogId>(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<unknown>) => {
try {
await mutation();
await onRefresh();
} catch (reason) {
onError(reason);
}
};
return (
<>
{view === "catalog" ? (
<CatalogView
workspace={workspace}
canManage={platformOwner}
onCreatePackage={() => 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" ? (
<InfrastructureView
workspace={workspace}
canManageCatalog={platformOwner}
canManageRoutes={capabilities.has("route.manage")}
onCreateEdge={() => 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" ? (
<HostsView
workspace={workspace}
canManageInfrastructure={capabilities.has("infrastructure.manage")}
canManageAssets={capabilities.has("asset.manage")}
canManageBindings={capabilities.has("binding.manage")}
onCreateHost={() => 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" ? <SessionsView workspace={workspace} /> : null}
{view === "bindings" ? (
<BindingsView
workspace={workspace}
canManage={capabilities.has("binding.manage")}
onCreate={() => setDialog("binding")}
onRevoke={(binding) => revokeDeviceBinding({
projectRef: workspace.project.projectRef,
bindingRef: binding.bindingRef,
resolutionCode: "operator.revoked",
}).then(onRefresh).catch(onError)}
/>
) : null}
{view === "commands" ? (
<CommandsView
workspace={workspace}
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
onRefresh={onRefresh}
onError={onError}
/>
) : null}
{view === "audit" ? <AuditView workspace={workspace} /> : null}
{view === "access" ? (
<AccessView
workspace={workspace}
canManage={capabilities.has("access.manage")}
onCreate={() => setDialog("grant")}
/>
) : null}
{view === "settings" ? (
<SettingsView
workspace={workspace}
canConfigure={capabilities.has("configuration.manage")}
onCreateConfiguration={() => setDialog("configuration")}
/>
) : null}
<AdapterPackageDialog
open={dialog === "adapter-package"}
onClose={close}
onCreated={completed}
onError={onError}
/>
<AdapterVersionDialog
open={dialog === "adapter-version"}
packages={workspace.adapterPackages}
onClose={close}
onCreated={completed}
onError={onError}
/>
<ModelProfileDialog
open={dialog === "model-profile"}
versions={workspace.adapterVersions}
onClose={close}
onCreated={completed}
onError={onError}
/>
<EdgeDialog
open={dialog === "edge"}
onClose={close}
onCreated={completed}
onError={onError}
/>
<RouteDialog
open={dialog === "route"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<BindingDialog
open={dialog === "binding"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<GrantDialog
open={dialog === "grant"}
projectRef={workspace.project.projectRef}
onClose={close}
onCreated={completed}
onError={onError}
/>
<ConfigurationDialog
open={dialog === "configuration"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<AssetDialog
open={dialog === "asset"}
projectRef={workspace.project.projectRef}
onClose={close}
onCreated={completed}
onError={onError}
/>
<AssetBindingDialog
open={dialog === "asset-binding"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<HostDialog
open={dialog === "host"}
projectRef={workspace.project.projectRef}
onClose={close}
onCreated={completed}
onError={onError}
/>
<EndpointDialog
open={dialog === "endpoint"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<DeploymentDialog
open={dialog === "deployment"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<ServiceInstanceDialog
open={dialog === "service-instance"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
<HealthObservationDialog
open={dialog === "health-observation"}
workspace={workspace}
onClose={close}
onCreated={completed}
onError={onError}
/>
</>
);
}
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 (
<ControlStack>
<ControlToolbar
copy="Adapter packages и model profiles — глобальный versioned каталог. B2 здесь не является отдельным продуктом."
actions={canManage ? <>
<Button size="compact" onClick={onCreatePackage}>Пакет</Button>
<Button size="compact" onClick={onCreateVersion} disabled={!workspace.adapterPackages.length}>Версия</Button>
<Button size="compact" variant="primary" onClick={onCreateProfile} disabled={!workspace.adapterVersions.length}>Профиль</Button>
</> : null}
/>
<ControlSection title="Model profiles" count={workspace.modelProfiles.length}>
<ResourceGrid empty="В доступном каталоге пока нет model profiles.">
{workspace.modelProfiles.map((profile) => (
<ResourceCard
key={profile.modelProfileRef}
eyebrow={`${profile.vendor} · ${profile.deviceType}`}
title={`${profile.model}`}
description={`${profile.protocol} · ${profile.modelProfileRef}`}
status={profile.lifecycleState}
meta={profile.capabilities}
action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? (
<Button size="compact" variant="primary" onClick={() => onActivateProfile(profile)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
</ControlSection>
<ControlSection title="Adapter versions" count={workspace.adapterVersions.length}>
<ResourceGrid empty="Версии адаптеров не зарегистрированы.">
{workspace.adapterVersions.map((version) => (
<ResourceCard
key={version.adapterVersionRef}
eyebrow={version.contractVersion}
title={version.version}
description={version.runtimePackageRef}
status={version.lifecycleState}
meta={[shortDigest(version.contentDigest), ...version.capabilities]}
action={canManage && version.lifecycleState === "draft" ? (
<Button size="compact" variant="primary" onClick={() => onActivateVersion(version)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
</ControlSection>
<ControlSection title="Adapter packages" count={workspace.adapterPackages.length}>
<ResourceGrid empty="Adapter packages не зарегистрированы.">
{workspace.adapterPackages.map((adapterPackage) => (
<ResourceCard
key={adapterPackage.adapterPackageRef}
eyebrow={adapterPackage.publisherRef}
title={adapterPackage.displayName}
description={adapterPackage.packageKey}
status={adapterPackage.lifecycleState}
meta={workspace.adapterVersions
.filter((version) => version.adapterPackageRef === adapterPackage.adapterPackageRef)
.map((version) => `${version.version} · ${version.lifecycleState}`)}
/>
))}
</ResourceGrid>
</ControlSection>
</ControlStack>
);
}
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 (
<ControlStack>
<ControlToolbar
copy="Edge — зарегистрированная внешняя роль. Route связывает проект, Edge, profile и логический listener без credentials."
actions={<>
{canManageCatalog ? <Button size="compact" onClick={onCreateEdge}>Новый Edge</Button> : null}
{canManageRoutes ? <Button size="compact" variant="primary" onClick={onCreateRoute} disabled={!workspace.edges.length || !workspace.modelProfiles.length}>Новый маршрут</Button> : null}
</>}
/>
<ControlSection title="Routes" count={workspace.routes.length}>
<ResourceGrid empty="Маршрутов в проекте пока нет.">
{workspace.routes.map((route) => (
<ResourceCard
key={route.routeRef}
eyebrow={`${route.protocol} · ${route.direction}`}
title={route.displayName}
description={`${route.edgeName}${route.profileName}`}
status={route.lifecycleState}
meta={[
route.listenerRef,
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
]}
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
</ControlSection>
<ControlSection title="Edges" count={workspace.edges.length}>
<ResourceGrid empty="Доступных Edge registrations нет.">
{workspace.edges.map((edge) => (
<ResourceCard
key={edge.edgeRef}
eyebrow="DEVICE GATEWAY EDGE"
title={edge.displayName}
description={edge.edgeKey}
status={edge.lifecycleState}
meta={edge.deploymentRef ? [edge.deploymentRef] : []}
action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? (
<Button size="compact" variant="primary" onClick={() => onActivateEdge(edge)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
</ControlSection>
</ControlStack>
);
}
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<void>;
onError: (reason: unknown) => void;
}) {
const [selectedHostRef, setSelectedHostRef] = useState<string | null>(null);
const topology = workspace.ontology;
const selectedHost = selectedHostRef
? topology.hosts.find((host) => host.hostRef === selectedHostRef) ?? null
: null;
if (selectedHost) {
return (
<HostTelemetryWorkspace
host={selectedHost}
services={topology.serviceInstances.filter((service) => service.hostRef === selectedHost.hostRef)}
onBack={() => setSelectedHostRef(null)}
onPoll={onPoll}
onError={onError}
/>
);
}
return (
<ControlStack>
<ControlToolbar
copy={`Канонический ontology catalog ${topology.ontology.catalogHash}: Host, endpoint, deployment и service instance существуют отдельно. Edge — опциональная роль service instance; credentials остаются server-side.`}
actions={canManageInfrastructure ? <>
<Button size="compact" onClick={onCreateHost}>Новый VPS</Button>
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
<Button size="compact" variant="primary" onClick={onCreateService} disabled={!topology.deployments.length}>Service</Button>
</> : null}
/>
<ControlSection title="VPS и хосты" count={topology.hosts.length}>
<ResourceGrid empty="VPS и хосты для проекта пока не зарегистрированы.">
{topology.hosts.map((host) => {
const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
return (
<ResourceCard
key={host.hostRef}
eyebrow="INFRASTRUCTURE / HOST"
title={host.displayName}
description={host.externalRef || host.hostKey}
status={host.health.state}
meta={[
`lifecycle · ${host.lifecycleState}`,
`health · ${host.health.freshness}`,
...(host.providerRef ? [`provider · ${host.providerRef}`] : []),
`${hostEndpoints.length} endpoints · ${hostServices.length} services`,
`management · ${host.managementCredentialConfigured ? "configured" : "unconfigured"}`,
]}
action={<>
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
{canManageInfrastructure ? (
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
) : null}
</>}
/>
);
})}
</ResourceGrid>
</ControlSection>
<ControlSection title="Service instances" count={topology.serviceInstances.length}>
<ResourceGrid empty="Service instances ещё не связаны с deployments.">
{topology.serviceInstances.map((service) => {
const edge = service.edgeRef
? workspace.edges.find((item) => item.edgeRef === service.edgeRef)
: null;
return (
<ResourceCard
key={service.serviceInstanceRef}
eyebrow={service.serviceRole}
title={service.displayName}
description={service.serviceKey}
status={edge?.channel.runtimeState || service.health.state}
meta={[
`service · ${service.lifecycleState}`,
`health · ${service.health.freshness}`,
...(edge ? [
`Edge · ${edge.displayName}`,
`Core↔Edge · ${edge.channel.runtimeState}`,
] : []),
service.deploymentRef,
]}
/>
);
})}
</ResourceGrid>
</ControlSection>
<ControlSection title="Deployments и endpoints" count={topology.deployments.length + topology.endpoints.length}>
<ResourceList empty="Deployments и endpoints отсутствуют.">
{topology.deployments.map((deployment) => (
<ResourceRow
key={deployment.deploymentRef}
title={deployment.displayName}
description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`}
status={deployment.lifecycleState}
trailing="deployment"
/>
))}
{topology.endpoints.map((endpoint) => (
<ResourceRow
key={endpoint.endpointRef}
title={endpoint.endpointKey}
description={endpoint.endpointUri}
status={endpoint.lifecycleState}
trailing={endpoint.purpose}
/>
))}
</ResourceList>
</ControlSection>
<ControlToolbar
copy="Asset — стабильный трайк или другой объект. B2 остаётся Device и связывается с Asset временным binding; замена трекера не меняет историю Asset."
actions={<>
{canManageAssets ? <Button size="compact" onClick={onCreateAsset}>Новый Asset</Button> : null}
{canManageBindings ? <Button size="compact" variant="primary" onClick={onCreateAssetBinding} disabled={!topology.assets.length || !workspace.devices.length}>Привязать tracker</Button> : null}
</>}
/>
<ControlSection title="Assets" count={topology.assets.length}>
<ResourceGrid empty="Assets проекта пока не созданы.">
{topology.assets.map((asset) => {
const activeBindings = topology.assetBindings.filter(
(binding) => binding.assetRef === asset.assetRef && !binding.validTo,
);
return (
<ResourceCard
key={asset.assetRef}
eyebrow="ASSET / STABLE IDENTITY"
title={asset.displayName}
description={asset.assetTypeRef}
status={asset.lifecycleState}
meta={[asset.assetKey, `${activeBindings.length} active device bindings`]}
/>
);
})}
</ResourceGrid>
</ControlSection>
<ControlSection title="Device ↔ Asset history" count={topology.assetBindings.length}>
<ResourceList empty="Tracker bindings отсутствуют.">
{topology.assetBindings.map((binding) => (
<ResourceRow
key={binding.assetBindingRef}
title={`${binding.deviceName}${binding.assetName}`}
description={`${binding.bindingKind} · ${binding.provenanceRef} · ${formatDate(binding.validFrom)}`}
status={binding.validTo ? "closed" : "active"}
trailing={!binding.validTo && canManageBindings ? (
<Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button>
) : formatDate(binding.validTo)}
/>
))}
</ResourceList>
</ControlSection>
<GlassSurface padding="md" tone="soft">
<p className="device-manager-card-copy">
Отсутствующее или просроченное health evidence отображается как unobserved, а не unreachable. Arbitrary WebSSH console отключена; будущая консоль потребует отдельной короткоживущей management session и break-glass аудита.
</p>
</GlassSurface>
</ControlStack>
);
}
function HostTelemetryWorkspace({
host,
services,
onBack,
onPoll,
onError,
}: {
host: InfrastructureHostView;
services: InfrastructureServiceInstanceView[];
onBack: () => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
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 runtimeServices = current?.services ?? [];
return (
<div className="host-telemetry-workspace">
<header className="host-telemetry-header">
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
<div className="host-telemetry-header__copy">
<small>СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</small>
<h2>{host.displayName}</h2>
<p>Аппаратный и процессинговый срез VPS. Метрики снимает host-agent; Device Core хранит только канонические наблюдения.</p>
</div>
<div className="host-telemetry-header__actions">
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : telemetry.freshness === "stale" ? "warning" : undefined}>
{telemetry.freshness === "fresh" ? "Свежие данные" : telemetry.freshness === "stale" ? "Данные устарели" : "Нет данных"}
</StatusBadge>
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
<Icon name="refresh" size={17} />
</IconButton>
</div>
</header>
<section className="host-telemetry-metric-grid" aria-label="Ключевые метрики VPS">
<TelemetryMetricCard label="CPU" value={formatPercent(current?.cpu.usagePercent)} points={cpuHistory} detail={formatLoad(current?.cpu)} />
<TelemetryMetricCard label="RAM" value={formatPercent(current?.memory.usedPercent)} points={memoryHistory} detail={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
<TelemetryMetricCard label="NETWORK RX" value={formatRate(networkRate.received)} points={receiveHistory} detail="входящий трафик" />
<TelemetryMetricCard label="NETWORK TX" value={formatRate(networkRate.sent)} points={sendHistory} detail="исходящий трафик" />
</section>
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading">
<div><small>HARDWARE</small><h3>{current?.hardware.hostname ?? host.hostKey}</h3></div>
<StatusBadge tone={telemetry.freshness === "fresh" ? "success" : "warning"}>{telemetry.state}</StatusBadge>
</div>
<div className="host-telemetry-facts">
<TelemetryFact label="Процессор" value={current?.hardware.cpuModel} />
<TelemetryFact label="Логические ядра" value={formatNullable(current?.hardware.logicalProcessors)} />
<TelemetryFact label="Память занята" value={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} />
<TelemetryFact label="Uptime" value={formatDuration(current?.system.uptimeSeconds)} />
<TelemetryFact label="Платформа" value={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || null} />
<TelemetryFact label="Kernel" value={current?.hardware.kernelRelease} />
<TelemetryFact label="Процессы" value={formatNullable(current?.system.processes.total)} />
<TelemetryFact label="Load average" value={formatLoad(current?.cpu)} />
</div>
</section>
<div className="host-telemetry-split">
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading"><div><small>STORAGE</small><h3>Файловые системы</h3></div><StatusBadge>{current?.disks.length ?? 0}</StatusBadge></div>
<div className="host-telemetry-list">
{(current?.disks ?? []).map((disk, index) => (
<div className="host-telemetry-list__row" key={`${disk.device}:${disk.mount}:${index}`}>
<span><strong>{disk.mount ?? disk.device ?? "Диск"}</strong><small>{[disk.device, disk.filesystem].filter(Boolean).join(" · ")}</small></span>
<span><strong>{formatPercent(disk.usedPercent)}</strong><small>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</small></span>
</div>
))}
{!current?.disks.length ? <div className="device-manager-panel-empty">Данные о дисках ещё не поступили.</div> : null}
</div>
</section>
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading"><div><small>NETWORK</small><h3>Сетевые интерфейсы</h3></div><StatusBadge>{current?.network.length ?? 0}</StatusBadge></div>
<div className="host-telemetry-list">
{(current?.network ?? []).filter((item) => item.interface !== "lo").map((item, index) => (
<div className="host-telemetry-list__row" key={`${item.interface}:${index}`}>
<span><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></span>
<span><strong> {formatMetricBytes(item.bytesReceived)}</strong><small> {formatMetricBytes(item.bytesSent)}</small></span>
</div>
))}
{!current?.network.filter((item) => item.interface !== "lo").length ? <div className="device-manager-panel-empty">Сетевые счётчики ещё не поступили.</div> : null}
</div>
</section>
</div>
<section className="host-telemetry-section">
<div className="host-telemetry-section__heading">
<div><small>PROCESSING RUNTIME</small><h3>Сервисы VPS</h3><p>Состояние systemd-юнитов и их ресурсный профиль.</p></div>
<StatusBadge tone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : "success"}>{runtimeServices.length} units</StatusBadge>
</div>
<div className="host-telemetry-service-grid">
{runtimeServices.map((service, index) => (
<div className="host-telemetry-service" key={`${service.name}:${index}`}>
<span><strong>{service.name ?? "systemd unit"}</strong><small>{service.subState ?? service.loadState ?? "—"}</small></span>
<span><StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge><small>{formatMetricBytes(service.memoryBytes)}</small></span>
</div>
))}
{!runtimeServices.length ? <div className="device-manager-panel-empty">Состояние сервисов ещё не поступило.</div> : null}
</div>
</section>
<section className="host-telemetry-section host-telemetry-evidence">
<div><small>ONTOLOGY / OBSERVATION</small><strong>{telemetry.observation?.entityId ?? "observation.observation"}</strong></div>
<div><small>Источник</small><strong>{current ? `${current.source.agent} ${current.source.agentVersion}` : "—"}</strong></div>
<div><small>Последнее наблюдение</small><strong>{formatDate(telemetry.observedAt)}</strong></div>
<div><small>Связанные сервисы</small><strong>{services.length}</strong></div>
</section>
</div>
);
}
function TelemetryMetricCard({ label, value, points, detail }: { label: string; value: string; points: Array<number | null>; detail: string }) {
return (
<div className="host-telemetry-metric">
<span>{label}</span>
<strong>{value}</strong>
<Sparkline values={points} />
<small>{detail}</small>
</div>
);
}
function Sparkline({ values }: { values: Array<number | null> }) {
const normalized = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
if (normalized.length < 2) return <div className="host-telemetry-sparkline host-telemetry-sparkline--empty" />;
const minimum = Math.min(...normalized);
const maximum = Math.max(...normalized);
const spread = Math.max(1, maximum - minimum);
const points = normalized.map((value, index) => {
const x = (index / (normalized.length - 1)) * 100;
const y = 28 - ((value - minimum) / spread) * 24;
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
return <svg className="host-telemetry-sparkline" viewBox="0 0 100 32" preserveAspectRatio="none" aria-hidden="true"><polyline points={points} /></svg>;
}
function TelemetryFact({ label, value }: { label: string; value: string | null | undefined }) {
return <div><small>{label}</small><strong>{value || "—"}</strong></div>;
}
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);
return {
received: nonNegativeRate(latestTotals.received - previousTotals.received, seconds),
sent: 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);
return nonNegativeRate(currentTotals[direction] - previousTotals[direction], seconds);
});
}
function networkTotals(network: InfrastructureHostView["telemetry"]["history"][number]["network"]) {
return network.filter((item) => item.interface !== "lo").reduce((total, item) => ({
received: total.received + (item.bytesReceived ?? 0),
sent: total.sent + (item.bytesSent ?? 0),
}), { received: 0, sent: 0 });
}
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 (
<ControlStack>
<ControlToolbar copy="Сессии принадлежат Gateway runtime. Device Manager только читает bounded presence/counter projection." />
<ResourceList empty="Gateway sessions пока не наблюдались.">
{workspace.sessions.map((session) => (
<ResourceRow
key={session.sessionRef}
title={session.deviceName || "Неидентифицированная сессия"}
description={`${session.routeName} · ${session.protocol} · ${formatDate(session.lastSeenAt)}`}
status={session.lifecycleState}
trailing={`${session.frameCount} frames · ${formatBytes(session.byteCount)}`}
/>
))}
</ResourceList>
</ControlStack>
);
}
function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
workspace: ProjectWorkspace;
canManage: boolean;
onCreate: () => void;
onRevoke: (binding: BindingView) => void;
}) {
return (
<ControlStack>
<ControlToolbar
copy="Binding создаёт только source approval. Active появится лишь после отдельного external proof от целевой системы."
actions={canManage ? <Button variant="primary" onClick={onCreate} disabled={!workspace.collections.length && !workspace.devices.length}>Новый binding</Button> : null}
/>
<ResourceList empty="Data bindings пока не создавались.">
{workspace.bindings.map((binding) => (
<ResourceRow
key={binding.bindingRef}
title={binding.displayName}
description={`${binding.source.displayName}${binding.target.kind}:${binding.target.ref}`}
status={binding.lifecycleState}
trailing={binding.lifecycleState !== "revoked" && canManage ? (
<Button size="compact" variant="danger" onClick={() => onRevoke(binding)}>Отозвать</Button>
) : binding.capabilities.join(", ")}
/>
))}
</ResourceList>
</ControlStack>
);
}
function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
workspace: ProjectWorkspace;
canDispatch: boolean;
onRefresh: () => Promise<void>;
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 (
<ControlStack>
<GlassSurface className="device-control-command-policy" padding="md" tone="soft">
<Icon name={enabled ? "check" : "lock"} />
<div>
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
<p>{enabled
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
</div>
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
</GlassSurface>
{enabled ? (
<GlassSurface padding="md" tone="soft">
<form className="device-control-command-form" onSubmit={submit}>
<Select
label="B2 трекер"
value={deviceRef}
onChange={setDeviceRef}
options={supportedDevices.map((device) => ({
value: device.deviceRef,
label: device.displayName,
description: device.session?.state || device.lifecycleState,
}))}
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
/>
<TextField
label="Код устройства"
type="password"
inputMode="numeric"
autoComplete="off"
value={accessCode}
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
pattern="[0-9]{6}"
minLength={6}
maxLength={6}
required
disabled={!canDispatch || submitting}
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
/>
<Button
type="submit"
variant="primary"
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
>
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
</Button>
</form>
</GlassSurface>
) : null}
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
{workspace.commands.map((command) => (
<ResourceRow
key={command.commandRef}
title={`${command.commandType} · ${command.deviceName}`}
description={`${command.riskClass} · expires ${formatDate(command.expiresAt)}`}
status={command.lifecycleState}
trailing={command.terminalReasonCode || command.commandKey}
/>
))}
</ResourceList>
</ControlStack>
);
}
function AuditView({ workspace }: { workspace: ProjectWorkspace }) {
return (
<ControlStack>
<ControlToolbar copy="Показывается immutable metadata projection. Audit payload намеренно не выдаётся в браузер." />
<ResourceList empty="Audit events для проекта отсутствуют.">
{workspace.auditEvents.map((event) => (
<ResourceRow
key={event.auditEventRef}
title={event.eventType}
description={`${event.actorRef} · ${formatDate(event.occurredAt)}`}
status="recorded"
trailing={event.deviceRef || event.discoveryRef || "project"}
/>
))}
</ResourceList>
</ControlStack>
);
}
function AccessView({ workspace, canManage, onCreate }: {
workspace: ProjectWorkspace;
canManage: boolean;
onCreate: () => void;
}) {
return (
<ControlStack>
<ControlToolbar
copy="Hub задаёт потолок, а Device Project grant — конкретную роль. Direct user grant имеет приоритет над group grants."
actions={canManage ? <Button variant="primary" onClick={onCreate}>Добавить доступ</Button> : null}
/>
<ResourceList empty="Project grants недоступны или ещё не созданы.">
{workspace.grants.map((grant) => (
<ResourceRow
key={grant.grantRef}
title={grant.principalRef}
description={`${grant.principalKind} · ${grant.projectRole}`}
status={grant.lifecycleState}
trailing={grant.capabilityDeny.length ? `deny: ${grant.capabilityDeny.join(", ")}` : "role capabilities"}
/>
))}
</ResourceList>
</ControlStack>
);
}
function SettingsView({ workspace, canConfigure, onCreateConfiguration }: {
workspace: ProjectWorkspace;
canConfigure: boolean;
onCreateConfiguration: () => void;
}) {
return (
<ControlStack>
<div className="device-control-policy-grid">
<PolicyCard label="Identifiers" value={workspace.policies.identifierProjection} />
<PolicyCard label="Audit payload" value={workspace.policies.auditPayloadProjection} />
<PolicyCard label="Command API" value={workspace.policies.commandPlanningApi} />
</div>
<ControlToolbar
copy="Configuration revisions immutable. Desired и applied — разные указатели; создание desired не означает применение устройством."
actions={canConfigure ? <Button variant="primary" onClick={onCreateConfiguration} disabled={!workspace.devices.length}>Новая desired revision</Button> : null}
/>
<ResourceList empty="Configuration state пока отсутствует.">
{workspace.configurationStates.map((state) => (
<ResourceRow
key={state.deviceRef}
title={state.deviceName}
description={`desired: ${shortRef(state.desiredConfigurationRevisionRef)} · applied: ${shortRef(state.appliedConfigurationRevisionRef)}`}
status={state.appliedConfigurationRevisionRef === state.desiredConfigurationRevisionRef ? "applied" : "pending"}
trailing={formatDate(state.updatedAt)}
/>
))}
</ResourceList>
<ControlSection title="Immutable revisions" count={workspace.configurationRevisions.length}>
<ResourceList empty="Configuration revisions отсутствуют.">
{workspace.configurationRevisions.map((revision) => (
<ResourceRow
key={revision.configurationRevisionRef}
title={`${revision.deviceName} · revision ${revision.revisionNumber}`}
description={revision.changeSummary || revision.modelProfileRef}
status="immutable"
trailing={shortDigest(revision.configurationDigest)}
/>
))}
</ResourceList>
</ControlSection>
</ControlStack>
);
}
function AdapterPackageDialog(props: DialogBaseProps) {
const [packageKey, setPackageKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [publisherRef, setPublisherRef] = useState("");
return <FormWindow {...props} id="adapter-package-form" title="Adapter package" submit={async () => {
await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" });
}}>
<KeyField label="Ключ пакета" value={packageKey} onChange={setPackageKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Publisher ref" value={publisherRef} onChange={(event) => setPublisherRef(event.target.value)} required />
</FormWindow>;
}
function AdapterVersionDialog({ packages, ...props }: DialogBaseProps & { packages: AdapterPackageView[] }) {
const [packageRef, setPackageRef] = useState(packages[0]?.adapterPackageRef ?? "");
const [version, setVersion] = useState("");
const [runtimeRef, setRuntimeRef] = useState("");
const [digest, setDigest] = useState("");
const [contractVersion, setContractVersion] = useState("");
const [capabilities, setCapabilities] = useState("");
useEffect(() => {
if (!packages.some((item) => item.adapterPackageRef === packageRef)) {
setPackageRef(packages[0]?.adapterPackageRef ?? "");
}
}, [packageRef, packages]);
return <FormWindow {...props} id="adapter-version-form" title="Версия адаптера" disabled={!packageRef} submit={async () => {
await registerAdapterVersion({
adapterPackageRef: packageRef,
version,
runtimePackageRef: runtimeRef,
contentDigest: digest,
contractVersion,
capabilities: commaList(capabilities),
lifecycleState: "draft",
});
}}>
<Select label="Adapter package" value={packageRef} onChange={setPackageRef} options={packages.map((item) => ({ value: item.adapterPackageRef, label: item.displayName }))} />
<TextField label="SemVer" value={version} onChange={(event) => setVersion(event.target.value)} required placeholder="1.0.0" />
<TextField label="Runtime artifact ref" value={runtimeRef} onChange={(event) => setRuntimeRef(event.target.value)} required />
<TextField label="Content digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
<TextField label="Contract version" value={contractVersion} onChange={(event) => setContractVersion(event.target.value)} required />
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="Через запятую" />
</FormWindow>;
}
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 <FormWindow {...props} id="model-profile-form" title="Model profile" disabled={!versionRef} submit={async () => {
await registerModelProfile({
adapterVersionRef: versionRef,
profileRef,
schemaVersion,
vendor,
model,
deviceType,
protocol: protocol.toUpperCase(),
schemaArtifactRef: schemaRef,
profileDigest: digest,
capabilities: commaList(capabilities),
lifecycleState: "draft",
});
}}>
<Select label="Adapter version" value={versionRef} onChange={setVersionRef} options={versions.map((item) => ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} />
<TextField label="Profile ref" value={profileRef} onChange={(event) => setProfileRef(event.target.value)} required />
<TextField label="Schema version" value={schemaVersion} onChange={(event) => setSchemaVersion(event.target.value)} required />
<TextField label="Vendor" value={vendor} onChange={(event) => setVendor(event.target.value)} required />
<TextField label="Model" value={model} onChange={(event) => setModel(event.target.value)} required />
<KeyField label="Device type" value={deviceType} onChange={setDeviceType} />
<TextField label="Protocol" value={protocol} onChange={(event) => setProtocol(event.target.value)} required />
<TextField label="Schema artifact ref" value={schemaRef} onChange={(event) => setSchemaRef(event.target.value)} required />
<TextField label="Profile digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} />
</FormWindow>;
}
function AssetDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
const [assetKey, setAssetKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [assetTypeRef, setAssetTypeRef] = useState("asset-type:delivery-trike");
return <FormWindow {...props} id="asset-form" title="Новый Asset" submit={async () => {
await ensureAsset({
projectRef,
assetKey,
displayName,
assetTypeRef,
lifecycleState: "active",
});
}}>
<KeyField label="Asset key" value={assetKey} onChange={setAssetKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Asset type ref" value={assetTypeRef} onChange={(event) => setAssetTypeRef(event.target.value)} required description="Канонический тип или стабильная ссылка на тип, не модель трекера." />
</FormWindow>;
}
function AssetBindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
const [assetRef, setAssetRef] = useState(workspace.ontology.assets[0]?.assetRef ?? "");
const [bindingKey, setBindingKey] = useState("");
const [bindingKind, setBindingKind] = useState<"tracking" | "installed" | "assigned">("tracking");
const [provenanceRef, setProvenanceRef] = useState("onboarding:device-manager");
useEffect(() => {
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
}
if (!workspace.ontology.assets.some((item) => item.assetRef === assetRef)) {
setAssetRef(workspace.ontology.assets[0]?.assetRef ?? "");
}
}, [assetRef, deviceRef, workspace]);
return <FormWindow {...props} id="asset-binding-form" title="Привязать Device к Asset" disabled={!deviceRef || !assetRef} submit={async () => {
await ensureAssetBinding({
projectRef: workspace.project.projectRef,
bindingKey,
deviceRef,
assetRef,
bindingKind,
validFrom: new Date().toISOString(),
provenanceRef,
});
}}>
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
<Select label="Asset" value={assetRef} onChange={setAssetRef} options={workspace.ontology.assets.map((item) => ({ value: item.assetRef, label: item.displayName, description: item.assetTypeRef }))} />
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
<Select label="Relation" value={bindingKind} onChange={setBindingKind} options={[
{ value: "tracking", label: "Tracking" },
{ value: "installed", label: "Installed" },
{ value: "assigned", label: "Assigned" },
]} />
<TextField label="Provenance ref" value={provenanceRef} onChange={(event) => setProvenanceRef(event.target.value)} required />
</FormWindow>;
}
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 <FormWindow {...props} id="host-form" title="Новый VPS / Host" submit={async () => {
await ensureInfrastructureHost({
projectRef,
hostKey,
displayName,
providerRef: providerRef || null,
externalRef: externalRef || null,
managementCredentialRef: credentialRef || null,
lifecycleState: "active",
});
}}>
<KeyField label="Host key" value={hostKey} onChange={setHostKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Provider ref" value={providerRef} onChange={(event) => setProviderRef(event.target.value)} placeholder="provider:beget" />
<TextField label="External resource ref" value={externalRef} onChange={(event) => setExternalRef(event.target.value)} placeholder="provider-resource:vps-123" />
<TextField label="Management credential ref" value={credentialRef} onChange={(event) => setCredentialRef(event.target.value)} placeholder="secret-ref:device-core/host-key" description="Только server-side secret reference. Пароль или приватный ключ сюда вводить нельзя." />
</FormWindow>;
}
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 <FormWindow {...props} id="endpoint-form" title="Host endpoint" disabled={!hostRef} submit={async () => {
await ensureInfrastructureEndpoint({
projectRef: workspace.project.projectRef,
hostRef,
endpointKey,
purpose,
endpointUri,
lifecycleState: "active",
});
}}>
<Select label="Host" value={hostRef} onChange={setHostRef} options={hosts.map((item) => ({ value: item.hostRef, label: item.displayName }))} />
<KeyField label="Endpoint key" value={endpointKey} onChange={setEndpointKey} />
<Select label="Purpose" value={purpose} onChange={setPurpose} options={[
{ value: "management", label: "Management" },
{ value: "monitoring", label: "Monitoring" },
{ value: "service", label: "Service" },
]} />
<TextField label="Endpoint URI" value={endpointUri} onChange={(event) => setEndpointUri(event.target.value)} required placeholder="ssh://203.0.113.10:22/" description="HTTPS, SSH или TCP. URI с userinfo, query или fragment будет отклонён." />
</FormWindow>;
}
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 <FormWindow {...props} id="deployment-form" title="Infrastructure deployment" disabled={!hostRef} submit={async () => {
await ensureInfrastructureDeployment({
projectRef: workspace.project.projectRef,
hostRef,
deploymentKey,
displayName,
artifactRef,
artifactDigest,
lifecycleState: "active",
});
}}>
<Select label="Host" value={hostRef} onChange={setHostRef} options={hosts.map((item) => ({ value: item.hostRef, label: item.displayName }))} />
<KeyField label="Deployment key" value={deploymentKey} onChange={setDeploymentKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Artifact ref" value={artifactRef} onChange={(event) => setArtifactRef(event.target.value)} required placeholder="artifact:device-edge/1.0.0" />
<TextField label="Artifact digest" value={artifactDigest} onChange={(event) => setArtifactDigest(event.target.value)} required placeholder="sha256:…" />
</FormWindow>;
}
function ServiceInstanceDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const hosts = workspace.ontology.hosts;
const [hostRef, setHostRef] = useState(hosts[0]?.hostRef ?? "");
const matchingDeployments = workspace.ontology.deployments.filter((item) => item.hostRef === hostRef);
const [deploymentRef, setDeploymentRef] = useState(matchingDeployments[0]?.deploymentRef ?? "");
const [edgeRef, setEdgeRef] = useState("");
const [serviceKey, setServiceKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [serviceRole, setServiceRole] = useState("device.edge");
useEffect(() => {
if (!hosts.some((item) => item.hostRef === hostRef)) setHostRef(hosts[0]?.hostRef ?? "");
if (!matchingDeployments.some((item) => item.deploymentRef === deploymentRef)) {
setDeploymentRef(matchingDeployments[0]?.deploymentRef ?? "");
}
}, [deploymentRef, hostRef, hosts, matchingDeployments]);
return <FormWindow {...props} id="service-instance-form" title="Service instance" disabled={!hostRef || !deploymentRef} submit={async () => {
await ensureInfrastructureServiceInstance({
projectRef: workspace.project.projectRef,
hostRef,
deploymentRef,
edgeRef: edgeRef || null,
serviceKey,
displayName,
serviceRole,
lifecycleState: "active",
});
}}>
<Select label="Host" value={hostRef} onChange={setHostRef} options={hosts.map((item) => ({ value: item.hostRef, label: item.displayName }))} />
<Select label="Deployment" value={deploymentRef} onChange={setDeploymentRef} options={matchingDeployments.map((item) => ({ value: item.deploymentRef, label: item.displayName }))} />
<Select label="Edge role" value={edgeRef} onChange={setEdgeRef} options={[
{ value: "", label: "Без Edge registration" },
...workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.channel.runtimeState })),
]} />
<KeyField label="Service key" value={serviceKey} onChange={setServiceKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Service role" value={serviceRole} onChange={(event) => setServiceRole(event.target.value.toLowerCase())} required placeholder="device.edge" />
</FormWindow>;
}
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 <FormWindow {...props} id="health-observation-form" title="Health evidence" disabled={!target} submit={async () => {
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<string, unknown>,
observedAt: observedAt.toISOString(),
expiresAt: new Date(observedAt.valueOf() + ttl * 60_000).toISOString(),
});
}}>
<Select label="Subject" value={target} onChange={setTarget} options={targets} />
<Select label="Observed state" value={observedState} onChange={setObservedState} options={[
{ value: "reachable", label: "Reachable" },
{ value: "degraded", label: "Degraded" },
{ value: "unreachable", label: "Unreachable" },
]} />
<TextField label="TTL, минут" value={ttlMinutes} onChange={(event) => setTtlMinutes(event.target.value)} required inputMode="numeric" />
<TextAreaField label="Bounded evidence JSON" value={evidence} onChange={(event) => setEvidence(event.target.value)} required />
<p className="device-manager-card-copy">Это ручное наблюдение с TTL. Автоматический probe должен писать тот же canonical contract от собственного source ref.</p>
</FormWindow>;
}
function EdgeDialog(props: DialogBaseProps) {
const [edgeKey, setEdgeKey] = useState("");
const [displayName, setDisplayName] = useState("");
const [deploymentRef, setDeploymentRef] = useState("");
return <FormWindow {...props} id="edge-form" title="Новый Edge" submit={async () => {
await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" });
}}>
<KeyField label="Edge key" value={edgeKey} onChange={setEdgeKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Deployment ref" value={deploymentRef} onChange={(event) => setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." />
</FormWindow>;
}
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 <FormWindow {...props} id="route-form" title="Новый маршрут" disabled={!edgeRef || !profileRef} submit={async () => {
await ensureRoute({
projectRef: workspace.project.projectRef,
routeKey,
displayName,
edgeRef,
modelProfileRef: profileRef,
listenerRef,
protocol: profile?.protocol || "INTERNAL",
direction: "telemetry",
lifecycleState: "draft",
});
}}>
<KeyField label="Route key" value={routeKey} onChange={setRouteKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<Select label="Edge" value={edgeRef} onChange={setEdgeRef} options={workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} />
<Select label="Model profile" value={profileRef} onChange={setProfileRef} options={workspace.modelProfiles.map((item) => ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} />
<TextField label="Listener ref" value={listenerRef} onChange={(event) => setListenerRef(event.target.value)} required />
<p className="device-manager-card-copy">Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.</p>
</FormWindow>;
}
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 <FormWindow {...props} id="binding-form" title="Новый data binding" disabled={!sourceValue} submit={async () => {
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),
});
}}>
<Select label="Source" value={sourceValue} onChange={setSourceValue} options={sources} />
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
<TextField label="Target kind" value={targetKind} onChange={(event) => setTargetKind(event.target.value)} required placeholder="foundry.application" />
<TextField label="Target ref" value={targetRef} onChange={(event) => setTargetRef(event.target.value)} required />
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="observe, inspect, configure, command" required />
</FormWindow>;
}
function GrantDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
const [principalKind, setPrincipalKind] = useState<"user" | "group">("user");
const [principalRef, setPrincipalRef] = useState("");
const [role, setRole] = useState("viewer");
const [allow, setAllow] = useState("");
const [deny, setDeny] = useState("");
return <FormWindow {...props} id="grant-form" title="Project access" submit={async () => {
await upsertProjectGrant({
projectRef,
principalKind,
principalRef,
projectRole: role,
capabilityAllow: commaList(allow),
capabilityDeny: commaList(deny),
lifecycleState: "active",
});
}}>
<Select label="Principal type" value={principalKind} onChange={setPrincipalKind} options={[{ value: "user", label: "User" }, { value: "group", label: "Group" }]} />
<TextField label="Principal ref" value={principalRef} onChange={(event) => setPrincipalRef(event.target.value)} required />
<Select label="Project role" value={role} onChange={setRole} options={["viewer", "operator", "engineer", "admin", "owner"].map((value) => ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} />
<TextField label="Capability allow" value={allow} onChange={(event) => setAllow(event.target.value)} description="Опциональные точечные добавления" />
<TextField label="Capability deny" value={deny} onChange={(event) => setDeny(event.target.value)} description="Deny имеет приоритет" />
</FormWindow>;
}
function ConfigurationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
const [configuration, setConfiguration] = useState("{\n \"reporting_interval_seconds\": 30\n}");
const [summary, setSummary] = useState("");
useEffect(() => {
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
}
}, [deviceRef, workspace.devices]);
return <FormWindow {...props} id="configuration-form" title="Новая desired configuration" disabled={!deviceRef} submit={async () => {
const parsed = JSON.parse(configuration) as Record<string, unknown>;
const created = await createConfigurationRevision({
projectRef: workspace.project.projectRef,
deviceRef,
configuration: parsed,
changeSummary: summary || null,
});
await setDesiredConfiguration({
projectRef: workspace.project.projectRef,
deviceRef,
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
});
}}>
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
<TextAreaField label="Configuration JSON" value={configuration} onChange={(event) => setConfiguration(event.target.value)} required />
<TextAreaField label="Change summary" value={summary} onChange={(event) => setSummary(event.target.value)} />
<p className="device-manager-card-copy">Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.</p>
</FormWindow>;
}
interface DialogBaseProps {
open: boolean;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
}
function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & {
id: string;
title: string;
submit: () => Promise<void>;
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 (
<Window open={open} title={title} onClose={onClose} footer={
<WindowFooterActions>
<Button variant="ghost" onClick={onClose}>Отмена</Button>
<Button type="submit" form={id} variant="primary" disabled={disabled || pending}>{pending ? "Сохраняем…" : "Сохранить"}</Button>
</WindowFooterActions>
}>
<form id={id} className="device-manager-form device-control-form" onSubmit={handleSubmit}>{children}</form>
</Window>
);
}
function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return <TextField label={label} value={value} onChange={(event) => onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />;
}
function ControlStack({ children }: { children: ReactNode }) {
return <div className="device-manager-stack device-control-stack">{children}</div>;
}
function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) {
return <div className="device-manager-panel-toolbar device-control-toolbar"><p>{copy}</p>{actions ? <div className="device-control-toolbar__actions">{actions}</div> : null}</div>;
}
function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) {
return <section className="device-control-section"><div className="device-control-section__title"><h3>{title}</h3><StatusBadge>{count}</StatusBadge></div>{children}</section>;
}
function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) {
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
return hasChildren ? <div className="device-control-resource-grid">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
}
function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{action}</>}>
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <StatusBadge key={item}>{item}</StatusBadge>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
</SettingsCard>;
}
function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
return hasChildren ? <div className="device-manager-entity-list">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
}
function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
return <GlassSurface className="device-manager-entity device-control-row" padding="md" tone="soft">
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
<span className="device-manager-entity__body"><strong>{title}</strong><small>{description}</small></span>
<span className="device-control-row__status"><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{typeof trailing === "string" ? <small>{trailing}</small> : trailing}</span>
</GlassSurface>;
}
function PolicyCard({ label, value }: { label: string; value: string }) {
return <GlassSurface padding="md" tone="soft"><small>{label}</small><strong>{value}</strong></GlassSurface>;
}
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)}`;
}