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

1861 lines
89 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, 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<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 [expandedHostRefs, setExpandedHostRefs] = useState<Set<string>>(() => new Set());
const inventoryRef = useRef<HTMLDivElement>(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 (
<HostTelemetryWorkspace
host={selectedHost}
services={topology.serviceInstances.filter((service) => service.hostRef === selectedHost.hostRef)}
onBack={() => setSelectedHostRef(null)}
onPoll={onPoll}
onError={onError}
/>
);
}
return (
<div className="infrastructure-system-workspace" ref={inventoryRef}>
<section className="infrastructure-overview-block">
<div className="infrastructure-workspace-lead">
<div>
<span className="infrastructure-eyebrow">ИНФРАСТРУКТУРА / VPS И ХОСТЫ</span>
<h2>VPS и хосты</h2>
<p>Вычислительные узлы проекта, их подключения и запущенные сервисы.</p>
</div>
{canManageInfrastructure ? (
<div className="infrastructure-workspace-actions">
<Button size="compact" variant="primary" onClick={onCreateHost}>Новый VPS</Button>
</div>
) : null}
</div>
<div className="infrastructure-overview-grid" aria-label="Сводка инфраструктуры">
<InfrastructureCount label="ХОСТЫ" value={topology.hosts.length} detail={`${topology.hosts.filter((host) => host.telemetry.freshness === "fresh").length} со свежими данными`} />
<InfrastructureCount label="ENDPOINTS" value={topology.endpoints.length} detail="точки подключения" />
<InfrastructureCount label="DEPLOYMENTS" value={topology.deployments.length} detail="развёрнутые контуры" />
<InfrastructureCount label="SERVICES" value={topology.serviceInstances.length} detail="экземпляры сервисов" />
</div>
</section>
<section className="infrastructure-section infrastructure-hosts-block">
<InfrastructureSectionHeading
eyebrow="ВЫЧИСЛИТЕЛЬНЫЕ УЗЛЫ"
title="Зарегистрированные хосты"
description="Компактный список VPS. Раскройте только тот хост, связи которого нужно посмотреть."
status={russianCount(topology.hosts.length, "хост", "хоста", "хостов")}
actions={canManageInfrastructure ? <>
<Button size="compact" onClick={onRecordHealth}>Наблюдение</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}
/>
{topology.hosts.length ? (
<div className="infrastructure-host-list">
{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 (
<article className="infrastructure-host-card" data-expanded={expanded ? "true" : undefined} key={host.hostRef}>
<header className="infrastructure-host-card__summary">
<div className="infrastructure-host-card__identity">
<span className="infrastructure-eyebrow">COMPUTE HOST</span>
<h4>{host.displayName}</h4>
<code>{host.externalRef || host.hostKey}</code>
</div>
<div className="infrastructure-host-card__actions">
<Button size="compact" variant="primary" onClick={() => setSelectedHostRef(host.hostRef)}>Мониторинг</Button>
<span
className="infrastructure-host-card__freshness"
data-freshness={host.telemetry.freshness}
role="status"
aria-label={freshnessLabel(host.telemetry.freshness)}
title={freshnessLabel(host.telemetry.freshness)}
/>
<IconButton
className="infrastructure-host-card__toggle"
label={expanded ? `Свернуть ${host.displayName}` : `Развернуть ${host.displayName}`}
aria-expanded={expanded}
aria-controls={detailsId}
onClick={() => toggleHost(host.hostRef)}
>
<Icon name="chevron-down" size={18} />
</IconButton>
</div>
</header>
{expanded ? (
<div className="infrastructure-host-card__details" id={detailsId}>
<dl className="infrastructure-host-card__facts">
<div><dt>Состояние</dt><dd>{host.lifecycleState}</dd></div>
<div><dt>Провайдер</dt><dd>{host.providerRef || "Не указан"}</dd></div>
<div><dt>Доступ управления</dt><dd>{host.managementCredentialConfigured ? "Настроен" : "Не настроен"}</dd></div>
<div><dt>Последнее наблюдение</dt><dd>{formatDate(host.telemetry.observedAt)}</dd></div>
</dl>
<div className="infrastructure-host-relations">
<section>
<header><span className="infrastructure-eyebrow">ENDPOINTS</span><strong>{hostEndpoints.length}</strong></header>
{hostEndpoints.length ? <div className="infrastructure-registry-list">{hostEndpoints.map((endpoint) => (
<InfrastructureRegistryRow key={endpoint.endpointRef} label={endpoint.purpose} title={endpoint.endpointKey} description={endpoint.endpointUri} status={endpoint.lifecycleState} />
))}</div> : <p>Точки подключения не зарегистрированы.</p>}
</section>
<section>
<header><span className="infrastructure-eyebrow">DEPLOYMENTS</span><strong>{hostDeployments.length}</strong></header>
{hostDeployments.length ? <div className="infrastructure-registry-list">{hostDeployments.map((deployment) => (
<InfrastructureRegistryRow key={deployment.deploymentRef} label="DEPLOYMENT" title={deployment.displayName} description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`} status={deployment.lifecycleState} />
))}</div> : <p>Развёртывания не зарегистрированы.</p>}
</section>
<section>
<header><span className="infrastructure-eyebrow">SERVICES</span><strong>{hostServices.length}</strong></header>
{hostServices.length ? <div className="infrastructure-registry-list">{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 <InfrastructureRegistryRow key={service.serviceInstanceRef} label={service.serviceRole} title={service.displayName} description={`${service.serviceKey} · ${edge?.displayName || "Edge не связан"}`} status={runtimeState} />;
})}</div> : <p>Сервисы не зарегистрированы.</p>}
</section>
</div>
</div>
) : null}
</article>
);
})}
</div>
) : <div className="infrastructure-empty">VPS и хосты для проекта пока не зарегистрированы.</div>}
</section>
<section className="infrastructure-section infrastructure-assets-block">
<InfrastructureSectionHeading
eyebrow="DEVICE ASSETS"
title="Объекты и трекеры"
description="Стабильные объекты проекта и история привязанных к ним устройств."
status={russianCount(topology.assets.length, "объект", "объекта", "объектов")}
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}
</>}
/>
{topology.assets.length ? (
<div className="infrastructure-asset-grid">
{topology.assets.map((asset) => {
const activeBindings = topology.assetBindings.filter((binding) => binding.assetRef === asset.assetRef && !binding.validTo);
return (
<GlassSurface className="infrastructure-asset-card" padding="md" tone="soft" key={asset.assetRef}>
<div><span className="infrastructure-eyebrow">{asset.assetTypeRef}</span><strong>{asset.displayName}</strong><small>{asset.assetKey}</small></div>
<div><StatusBadge tone={statusTone(asset.lifecycleState)}>{asset.lifecycleState}</StatusBadge><small>{activeBindings.length} активных привязок</small></div>
</GlassSurface>
);
})}
</div>
) : <div className="infrastructure-empty">Объекты проекта пока не созданы.</div>}
{topology.assetBindings.length ? (
<div className="infrastructure-registry-list">
{topology.assetBindings.map((binding) => (
<div className="infrastructure-registry-row" key={binding.assetBindingRef}>
<div><span className="infrastructure-eyebrow">DEVICE ASSET</span><strong>{binding.deviceName} {binding.assetName}</strong><small>{binding.bindingKind} · {formatDate(binding.validFrom)}</small></div>
<div><StatusBadge tone={binding.validTo ? "neutral" : "success"}>{binding.validTo ? "Закрыта" : "Активна"}</StatusBadge>{!binding.validTo && canManageBindings ? <Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button> : null}</div>
</div>
))}
</div>
) : null}
</section>
</div>
);
}
function HostTelemetryWorkspace({
host,
services,
onBack,
onPoll,
onError,
}: {
host: InfrastructureHostView;
services: InfrastructureServiceInstanceView[];
onBack: () => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const workspaceRef = useRef<HTMLDivElement>(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 (
<div className="infrastructure-system-workspace host-monitoring-workspace" ref={workspaceRef}>
<section className="infrastructure-workspace-lead">
<div>
<span className="infrastructure-eyebrow">СИСТЕМА / ВЫЧИСЛИТЕЛЬНЫЕ МОДУЛИ</span>
<h2>{host.displayName}</h2>
<p>Аппаратный и процессинговый срез выбранного VPS. Последнее обновление: {formatDate(telemetry.observedAt)}.</p>
</div>
<div className="infrastructure-workspace-actions">
<StatusBadge tone={freshnessTone(telemetry.freshness)}>{freshnessLabel(telemetry.freshness)}</StatusBadge>
<IconButton label="Обновить телеметрию" onClick={() => onPoll().catch(onError)}>
<Icon name="refresh" size={17} />
</IconButton>
<IconButton label="Вернуться к VPS и хостам" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
</div>
</section>
<section className="host-monitoring-series-grid" aria-label="Аппаратная телеметрия VPS">
<HostTelemetrySeries label="CPU" value={formatPercent(current?.cpu.usagePercent)} resource={formatLoad(current?.cpu)} values={cpuHistory} domain={cpuDomain} />
<HostTelemetrySeries label="RAM" value={formatPercent(current?.memory.usedPercent)} resource={formatUsedTotal(current?.memory.usedBytes, current?.memory.totalBytes)} values={memoryHistory} domain={memoryDomain} />
<HostTelemetrySeries label="NETWORK RX" value={formatRate(networkRate.received)} resource={networkRate.received == null ? "нет данных" : "входящий трафик"} values={receiveHistory} />
<HostTelemetrySeries label="NETWORK TX" value={formatRate(networkRate.sent)} resource={networkRate.sent == null ? "нет данных" : "исходящий трафик"} values={sendHistory} />
</section>
<GlassSurface className="host-monitoring-hardware" padding="lg">
<InfrastructureSectionHeading
eyebrow="HARDWARE"
title={current?.hardware.hostname ?? host.hostKey}
description={[current?.hardware.platform, current?.hardware.architecture].filter(Boolean).join(" / ") || "Аппаратный профиль недоступен"}
status={telemetry.state}
statusTone={freshnessTone(telemetry.freshness)}
/>
<div className="host-monitoring-hardware-facts">
<dl>
<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)} />
</dl>
<dl>
<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)} />
</dl>
</div>
<div className="host-monitoring-disk-list">
{(current?.disks ?? []).map((disk, index) => (
<div key={`${disk.device}:${disk.mount}:${index}`}>
<span>Диск {disk.mount ?? disk.device ?? "—"}</span>
<strong>{formatUsedTotal(disk.usedBytes, disk.totalBytes)}</strong>
</div>
))}
{!current?.disks.length ? <div className="infrastructure-empty">Данные о дисках ещё не поступили.</div> : null}
</div>
</GlassSurface>
<section className="infrastructure-section">
<InfrastructureSectionHeading
eyebrow="NETWORK"
title="Сетевые интерфейсы"
description="Счётчики трафика и ошибок по активным интерфейсам VPS."
status={russianCount(networkInterfaces.length, "интерфейс", "интерфейса", "интерфейсов")}
/>
{networkInterfaces.length ? (
<div className="host-monitoring-network-grid">
{networkInterfaces.map((item, index) => (
<GlassSurface className="host-monitoring-network-card" padding="md" tone="soft" key={`${item.interface}:${index}`}>
<header><strong>{item.interface ?? "Интерфейс"}</strong><small>{formatPackets(item.packetsReceived, item.packetsSent)}</small></header>
<dl>
<div><dt>Получено</dt><dd>{formatMetricBytes(item.bytesReceived)}</dd></div>
<div><dt>Отправлено</dt><dd>{formatMetricBytes(item.bytesSent)}</dd></div>
<div><dt>Ошибки RX / TX</dt><dd>{formatNullable(item.errorsReceived)} / {formatNullable(item.errorsSent)}</dd></div>
<div><dt>Потери RX / TX</dt><dd>{formatNullable(item.droppedReceived)} / {formatNullable(item.droppedSent)}</dd></div>
</dl>
</GlassSurface>
))}
</div>
) : <div className="infrastructure-empty">Сетевые счётчики ещё не поступили.</div>}
</section>
<section className="infrastructure-section">
<InfrastructureSectionHeading
eyebrow="PROCESSING RUNTIME"
title="Сервисы VPS"
description={`Состояние systemd-юнитов; с хостом связано ${russianCount(services.length, "сервис", "сервиса", "сервисов")} Device Core.`}
status={russianCount(runtimeServices.length, "юнит", "юнита", "юнитов")}
statusTone={runtimeServices.some((service) => service.activeState === "failed") ? "danger" : runtimeServices.length ? "success" : "warning"}
/>
{runtimeServices.length ? (
<div className="infrastructure-runtime-grid">
{runtimeServices.map((service, index) => (
<GlassSurface className="host-monitoring-runtime-card" padding="md" tone="soft" key={`${service.name}:${index}`}>
<header>
<div><span className="infrastructure-eyebrow">SYSTEMD UNIT</span><h3>{service.name ?? "systemd unit"}</h3><code>{service.subState ?? "—"}</code></div>
<StatusBadge tone={service.activeState === "active" ? "success" : service.activeState === "failed" ? "danger" : "warning"}>{service.activeState ?? "unknown"}</StatusBadge>
</header>
<dl>
<div><dt>Load</dt><dd>{service.loadState ?? "—"}</dd></div>
<div><dt>Память</dt><dd>{formatMetricBytes(service.memoryBytes)}</dd></div>
<div><dt>Перезапуски</dt><dd>{formatNullable(service.restarts)}</dd></div>
<div><dt>PID</dt><dd>{formatNullable(service.pid)}</dd></div>
</dl>
</GlassSurface>
))}
</div>
) : <div className="infrastructure-empty">Состояние сервисов ещё не поступило.</div>}
</section>
</div>
);
}
function InfrastructureCount({ label, value, detail }: { label: string; value: number; detail: string }) {
return (
<div className="infrastructure-count-card">
<span>{label}</span><strong>{value}</strong><small>{detail}</small>
</div>
);
}
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 (
<header className="infrastructure-section-heading">
<div><span className="infrastructure-eyebrow">{eyebrow}</span><h3>{title}</h3><p>{description}</p></div>
<div className="infrastructure-section-actions"><StatusBadge tone={tone}>{status}</StatusBadge>{actions}</div>
</header>
);
}
function InfrastructureRegistryRow({ label, title, description, status }: { label: string; title: string; description: string; status: string }) {
return (
<div className="infrastructure-registry-row">
<div><span className="infrastructure-eyebrow">{label}</span><strong>{title}</strong><small>{description}</small></div>
<StatusBadge tone={statusTone(status)}>{status}</StatusBadge>
</div>
);
}
type TelemetryLineDomain = {
minimum: number;
maximum: number;
label?: string;
};
function HostTelemetrySeries({ label, values, value, resource, domain }: { label: string; values: Array<number | null>; value: string; resource?: string | null; domain?: TelemetryLineDomain | null }) {
const points = telemetryLinePoints(values, domain);
return (
<div className="host-monitoring-series">
<div>
<span className="host-monitoring-series__label"><span>{label}</span>{resource ? <small>{resource}</small> : null}</span>
<strong>{value}</strong>
</div>
<svg viewBox="0 0 100 38" preserveAspectRatio="none" role="img" aria-label={`${label}: ${value}`}>
<path d="M0 37 H100" />
{points ? <polyline points={points} /> : null}
</svg>
{domain?.label ? <small className="host-monitoring-series__range">{domain.label}</small> : null}
</div>
);
}
function telemetryLinePoints(values: Array<number | null>, 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<number | null>, 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 <div><dt>{label}</dt><dd>{value || "—"}</dd></div>;
}
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<HTMLElement>(".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 (
<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)}`;
}