feat(manager): manage ontology assets and infrastructure
This commit is contained in:
@@ -14,12 +14,20 @@ import {
|
||||
|
||||
import {
|
||||
createConfigurationRevision,
|
||||
closeAssetBinding,
|
||||
ensureAsset,
|
||||
ensureAssetBinding,
|
||||
ensureAdapterPackage,
|
||||
ensureDeviceBinding,
|
||||
ensureEdge,
|
||||
ensureInfrastructureDeployment,
|
||||
ensureInfrastructureEndpoint,
|
||||
ensureInfrastructureHost,
|
||||
ensureInfrastructureServiceInstance,
|
||||
ensureRoute,
|
||||
registerAdapterVersion,
|
||||
registerModelProfile,
|
||||
recordHealthObservation,
|
||||
revokeDeviceBinding,
|
||||
sendServicePing,
|
||||
setDesiredConfiguration,
|
||||
@@ -28,9 +36,11 @@ import {
|
||||
import type {
|
||||
AdapterPackageView,
|
||||
AdapterVersionView,
|
||||
AssetBindingView,
|
||||
BindingView,
|
||||
DeviceManagerSession,
|
||||
EdgeView,
|
||||
InfrastructureHostView,
|
||||
ModelProfileView,
|
||||
ProjectWorkspace,
|
||||
} from "./types";
|
||||
@@ -55,6 +65,13 @@ type DialogId =
|
||||
| "binding"
|
||||
| "grant"
|
||||
| "configuration"
|
||||
| "asset"
|
||||
| "asset-binding"
|
||||
| "host"
|
||||
| "endpoint"
|
||||
| "deployment"
|
||||
| "service-instance"
|
||||
| "health-observation"
|
||||
| null;
|
||||
|
||||
export function DeviceControlView({
|
||||
@@ -149,8 +166,21 @@ export function DeviceControlView({
|
||||
{view === "hosts" ? (
|
||||
<HostsView
|
||||
workspace={workspace}
|
||||
canManage={platformOwner}
|
||||
onCreateEdge={() => setDialog("edge")}
|
||||
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(),
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||||
@@ -244,6 +274,55 @@ export function DeviceControlView({
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -380,44 +459,165 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
|
||||
);
|
||||
}
|
||||
|
||||
function HostsView({ workspace, canManage, onCreateEdge }: {
|
||||
function HostsView({
|
||||
workspace,
|
||||
canManageInfrastructure,
|
||||
canManageAssets,
|
||||
canManageBindings,
|
||||
onCreateHost,
|
||||
onCreateEndpoint,
|
||||
onCreateDeployment,
|
||||
onCreateService,
|
||||
onRecordHealth,
|
||||
onCreateAsset,
|
||||
onCreateAssetBinding,
|
||||
onCloseAssetBinding,
|
||||
}: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreateEdge: () => void;
|
||||
canManageInfrastructure: boolean;
|
||||
canManageAssets: boolean;
|
||||
canManageBindings: boolean;
|
||||
onCreateHost: () => void;
|
||||
onCreateEndpoint: () => void;
|
||||
onCreateDeployment: () => void;
|
||||
onCreateService: () => void;
|
||||
onRecordHealth: () => void;
|
||||
onCreateAsset: () => void;
|
||||
onCreateAssetBinding: () => void;
|
||||
onCloseAssetBinding: (binding: AssetBindingView) => void;
|
||||
}) {
|
||||
const topology = workspace.ontology;
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="VPS-хосты показаны через зарегистрированную роль Edge: состояние берётся из pinned mTLS Core↔Edge канала, а связи — из маршрутов проекта. Адреса, ключи и credentials в браузер не выдаются."
|
||||
actions={canManage ? <Button size="compact" variant="primary" onClick={onCreateEdge}>Новый VPS Edge</Button> : null}
|
||||
copy={`Канонический ontology catalog ${topology.ontology.catalogHash}: Host, endpoint, deployment и service instance существуют отдельно. Edge — опциональная роль service instance; credentials остаются server-side.`}
|
||||
actions={canManageInfrastructure ? <>
|
||||
<Button size="compact" onClick={onCreateHost}>Новый VPS</Button>
|
||||
<Button size="compact" onClick={onCreateEndpoint} disabled={!topology.hosts.length}>Endpoint</Button>
|
||||
<Button size="compact" onClick={onCreateDeployment} disabled={!topology.hosts.length}>Deployment</Button>
|
||||
<Button size="compact" variant="primary" onClick={onCreateService} disabled={!topology.deployments.length}>Service</Button>
|
||||
</> : null}
|
||||
/>
|
||||
<ControlSection title="VPS и Edge-хосты" count={workspace.edges.length}>
|
||||
<ResourceGrid empty="VPS/Edge-хосты для проекта пока не зарегистрированы.">
|
||||
{workspace.edges.map((edge) => {
|
||||
const routes = workspace.routes.filter((route) => route.edgeRef === edge.edgeRef);
|
||||
const runtimeState = edge.channel?.runtimeState ?? "unobserved";
|
||||
<ControlSection title="VPS и хосты" count={topology.hosts.length}>
|
||||
<ResourceGrid empty="VPS и хосты для проекта пока не зарегистрированы.">
|
||||
{topology.hosts.map((host) => {
|
||||
const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
|
||||
const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
|
||||
return (
|
||||
<ResourceCard
|
||||
key={edge.edgeRef}
|
||||
eyebrow="VPS / EDGE HOST"
|
||||
title={edge.displayName}
|
||||
description={edge.deploymentRef || edge.edgeKey}
|
||||
status={runtimeState}
|
||||
key={host.hostRef}
|
||||
eyebrow="INFRASTRUCTURE / HOST"
|
||||
title={host.displayName}
|
||||
description={host.externalRef || host.hostKey}
|
||||
status={host.health.state}
|
||||
meta={[
|
||||
`registration · ${edge.lifecycleState}`,
|
||||
`channel · ${edge.channel?.lifecycleState ?? "disabled"}`,
|
||||
`${routes.length} ${routes.length === 1 ? "маршрут" : "маршрутов"}`,
|
||||
...(edge.channel?.generationRef ? [edge.channel.generationRef] : []),
|
||||
...(edge.channel?.lastErrorCode ? [`error · ${edge.channel.lastErrorCode}`] : []),
|
||||
`lifecycle · ${host.lifecycleState}`,
|
||||
`health · ${host.health.freshness}`,
|
||||
...(host.providerRef ? [`provider · ${host.providerRef}`] : []),
|
||||
`${hostEndpoints.length} endpoints · ${hostServices.length} services`,
|
||||
`management · ${host.managementCredentialConfigured ? "configured" : "unconfigured"}`,
|
||||
]}
|
||||
action={canManageInfrastructure ? (
|
||||
<Button size="compact" onClick={onRecordHealth}>Health evidence</Button>
|
||||
) : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Service instances" count={topology.serviceInstances.length}>
|
||||
<ResourceGrid empty="Service instances ещё не связаны с deployments.">
|
||||
{topology.serviceInstances.map((service) => {
|
||||
const edge = service.edgeRef
|
||||
? workspace.edges.find((item) => item.edgeRef === service.edgeRef)
|
||||
: null;
|
||||
return (
|
||||
<ResourceCard
|
||||
key={service.serviceInstanceRef}
|
||||
eyebrow={service.serviceRole}
|
||||
title={service.displayName}
|
||||
description={service.serviceKey}
|
||||
status={edge?.channel.runtimeState || service.health.state}
|
||||
meta={[
|
||||
`service · ${service.lifecycleState}`,
|
||||
`health · ${service.health.freshness}`,
|
||||
...(edge ? [
|
||||
`Edge · ${edge.displayName}`,
|
||||
`Core↔Edge · ${edge.channel.runtimeState}`,
|
||||
] : []),
|
||||
service.deploymentRef,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Deployments и endpoints" count={topology.deployments.length + topology.endpoints.length}>
|
||||
<ResourceList empty="Deployments и endpoints отсутствуют.">
|
||||
{topology.deployments.map((deployment) => (
|
||||
<ResourceRow
|
||||
key={deployment.deploymentRef}
|
||||
title={deployment.displayName}
|
||||
description={`${deployment.artifactRef} · ${shortDigest(deployment.artifactDigest)}`}
|
||||
status={deployment.lifecycleState}
|
||||
trailing="deployment"
|
||||
/>
|
||||
))}
|
||||
{topology.endpoints.map((endpoint) => (
|
||||
<ResourceRow
|
||||
key={endpoint.endpointRef}
|
||||
title={endpoint.endpointKey}
|
||||
description={endpoint.endpointUri}
|
||||
status={endpoint.lifecycleState}
|
||||
trailing={endpoint.purpose}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlSection>
|
||||
<ControlToolbar
|
||||
copy="Asset — стабильный трайк или другой объект. B2 остаётся Device и связывается с Asset временным binding; замена трекера не меняет историю Asset."
|
||||
actions={<>
|
||||
{canManageAssets ? <Button size="compact" onClick={onCreateAsset}>Новый Asset</Button> : null}
|
||||
{canManageBindings ? <Button size="compact" variant="primary" onClick={onCreateAssetBinding} disabled={!topology.assets.length || !workspace.devices.length}>Привязать tracker</Button> : null}
|
||||
</>}
|
||||
/>
|
||||
<ControlSection title="Assets" count={topology.assets.length}>
|
||||
<ResourceGrid empty="Assets проекта пока не созданы.">
|
||||
{topology.assets.map((asset) => {
|
||||
const activeBindings = topology.assetBindings.filter(
|
||||
(binding) => binding.assetRef === asset.assetRef && !binding.validTo,
|
||||
);
|
||||
return (
|
||||
<ResourceCard
|
||||
key={asset.assetRef}
|
||||
eyebrow="ASSET / STABLE IDENTITY"
|
||||
title={asset.displayName}
|
||||
description={asset.assetTypeRef}
|
||||
status={asset.lifecycleState}
|
||||
meta={[asset.assetKey, `${activeBindings.length} active device bindings`]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Device ↔ Asset history" count={topology.assetBindings.length}>
|
||||
<ResourceList empty="Tracker bindings отсутствуют.">
|
||||
{topology.assetBindings.map((binding) => (
|
||||
<ResourceRow
|
||||
key={binding.assetBindingRef}
|
||||
title={`${binding.deviceName} → ${binding.assetName}`}
|
||||
description={`${binding.bindingKind} · ${binding.provenanceRef} · ${formatDate(binding.validFrom)}`}
|
||||
status={binding.validTo ? "closed" : "active"}
|
||||
trailing={!binding.validTo && canManageBindings ? (
|
||||
<Button size="compact" onClick={() => onCloseAssetBinding(binding)}>Закрыть</Button>
|
||||
) : formatDate(binding.validTo)}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlSection>
|
||||
<GlassSurface padding="md" tone="soft">
|
||||
<p className="device-manager-card-copy">
|
||||
Общий реестр произвольных VPS, deployments, services и управляемая консоль требуют отдельного канонического ontology package. Текущий экран намеренно отображает только уже существующую проверяемую Edge-инфраструктуру.
|
||||
Отсутствующее или просроченное health evidence отображается как unobserved, а не unreachable. Arbitrary WebSSH console отключена; будущая консоль потребует отдельной короткоживущей management session и break-glass аудита.
|
||||
</p>
|
||||
</GlassSurface>
|
||||
</ControlStack>
|
||||
@@ -753,6 +953,226 @@ function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions
|
||||
</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("");
|
||||
|
||||
Reference in New Issue
Block a user