1861 lines
89 KiB
TypeScript
1861 lines
89 KiB
TypeScript
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)}`;
|
||
}
|