diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md index c57f78a..fa5e3f7 100644 --- a/apps/device-manager/README.md +++ b/apps/device-manager/README.md @@ -20,8 +20,17 @@ behavior; projects, inventory, collections and access remain shared Device Core The project workspace covers inventory, discovery, collections, adapter/profile metadata, Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable -audit metadata and project grants. Navigation and actions are derived from effective project -capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner. +audit metadata and project grants. It also joins the official ontology projection for stable +Assets, temporal Device-to-Asset bindings, provider-neutral Hosts, endpoints, deployments, +service instances and freshness-bounded health observations. Navigation and actions are +derived from effective project capabilities. Global adapter/profile/Edge mutation is +additionally restricted to a Hub owner. + +Host credentials are accepted only as opaque `secret-ref:*` values by the server-side Core +command. The browser projection receives only `managementCredentialConfigured`; it never +receives the reference or secret. Missing or expired health evidence is rendered as +`unobserved`, never inferred as `unreachable`. Arbitrary WebSSH remains disabled pending a +separate short-lived management-session and break-glass design. Command planning and transport intentionally have no Device Manager mutation route yet. The UI never presents `sent` as success: `acknowledged` and `verified` remain different diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs index 7132a5d..830ce9a 100644 --- a/apps/device-manager/server/device-core-client.mjs +++ b/apps/device-manager/server/device-core-client.mjs @@ -13,6 +13,14 @@ const commandRoutes = new Map([ ["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"], ["devices:claim", "/internal/v1/management/devices:claim"], ["devices:update", "/internal/v1/management/devices:update"], + ["assets:ensure", "/internal/v1/management/assets:ensure"], + ["asset-bindings:ensure", "/internal/v1/management/asset-bindings:ensure"], + ["asset-bindings:close", "/internal/v1/management/asset-bindings:close"], + ["infrastructure-hosts:ensure", "/internal/v1/management/infrastructure-hosts:ensure"], + ["infrastructure-endpoints:ensure", "/internal/v1/management/infrastructure-endpoints:ensure"], + ["infrastructure-deployments:ensure", "/internal/v1/management/infrastructure-deployments:ensure"], + ["infrastructure-service-instances:ensure", "/internal/v1/management/infrastructure-service-instances:ensure"], + ["health-observations:record", "/internal/v1/management/health-observations:record"], ["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"], ["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"], [ @@ -61,8 +69,13 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = { }, async getWorkspace(actor, projectRef) { const projectId = entityId(projectRef, "project"); - return request(`/internal/v1/query/projects/${projectId}/workspace`, actor) - .then((body) => body.workspace); + const [workspace, ontology] = await Promise.all([ + request(`/internal/v1/query/projects/${projectId}/workspace`, actor) + .then((body) => body.workspace), + request(`/internal/v1/query/projects/${projectId}/ontology`, actor) + .then((body) => body.projection), + ]); + return { ...workspace, ontology }; }, async execute(command, actor, input, idempotencyKey) { const pathname = commandRoutes.get(command); @@ -100,6 +113,13 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) { const configurationStates = new Map(); const auditEvents = []; const commands = new Map(); + const assets = new Map(); + const assetBindings = new Map(); + const hosts = new Map(); + const endpoints = new Map(); + const deployments = new Map(); + const serviceInstances = new Map(); + const healthObservations = new Map(); function now() { return new Date().toISOString(); @@ -166,6 +186,53 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) { identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only", auditPayloadProjection: "metadata-only", }, + ontology: ontologyProjection(projectRef), + }; + } + + function ontologyProjection(projectRef) { + const projectHosts = projectValues(hosts, projectRef); + const projectServices = projectValues(serviceInstances, projectRef); + const withHealth = (subjectKind, value, subjectRef) => ({ + ...value, + health: latestPreviewHealth(projectRef, subjectKind, subjectRef), + }); + return { + ontology: { + catalogHash: "229c61c02a790906", + packages: ["asset", "device", "infrastructure", "observation"], + }, + assets: projectValues(assets, projectRef), + assetBindings: projectValues(assetBindings, projectRef), + hosts: projectHosts.map((host) => withHealth("host", host, host.hostRef)), + endpoints: projectValues(endpoints, projectRef), + deployments: projectValues(deployments, projectRef), + serviceInstances: projectServices.map((service) => + withHealth("service-instance", service, service.serviceInstanceRef)), + policies: { + restrictedIdentifiers: "masked-only", + managementCredentials: "opaque-reference-only", + missingHealthEvidence: "unobserved-not-unhealthy", + arbitraryConsole: "disabled", + }, + }; + } + + function latestPreviewHealth(projectRef, subjectKind, subjectRef) { + const values = projectValues(healthObservations, projectRef) + .filter((item) => item.subjectKind === subjectKind && item.subjectRef === subjectRef) + .sort((left, right) => right.observedAt.localeCompare(left.observedAt)); + const latest = values[0]; + if (!latest) return { state: "unobserved", freshness: "missing", observationRef: null }; + const fresh = new Date(latest.expiresAt).valueOf() > Date.now(); + return { + state: fresh ? latest.observedState : "unobserved", + freshness: fresh ? "fresh" : "stale", + lastObservedState: latest.observedState, + evidenceClass: latest.evidenceClass, + observedAt: latest.observedAt, + expiresAt: latest.expiresAt, + observationRef: latest.healthObservationRef, }; } @@ -608,6 +675,167 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) { if (command === "devices:claim") { throw serviceError("device_discovery_not_found", 404); } + if (command === "assets:ensure") { + if (!projects.has(input.projectRef)) throw serviceError("device_project_not_found", 404); + const existing = projectValues(assets, input.projectRef) + .find((item) => item.assetKey === input.assetKey); + const assetRef = existing?.assetRef || `asset:${randomUUID()}`; + const asset = { + assetRef, + projectRef: input.projectRef, + assetKey: input.assetKey, + displayName: input.displayName, + assetTypeRef: input.assetTypeRef, + lifecycleState: input.lifecycleState ?? "active", + ontology: previewOntology("asset.asset"), + }; + assets.set(assetRef, asset); + audit(actor, input.projectRef, createdEvent(existing, "asset")); + return { replayed: false, result: { created: !existing, asset } }; + } + if (command === "asset-bindings:ensure") { + const device = devices.get(input.deviceRef); + const asset = assets.get(input.assetRef); + if (!device || device.projectRef !== input.projectRef) { + throw serviceError("device_not_found", 404); + } + if (!asset || asset.projectRef !== input.projectRef) { + throw serviceError("device_asset_not_found", 404); + } + const existing = projectValues(assetBindings, input.projectRef) + .find((item) => item.bindingKey === input.bindingKey); + const assetBindingRef = existing?.assetBindingRef || `asset-binding:${randomUUID()}`; + const assetBinding = { + assetBindingRef, + projectRef: input.projectRef, + bindingKey: input.bindingKey, + deviceRef: input.deviceRef, + deviceName: device.displayName, + assetRef: input.assetRef, + assetName: asset.displayName, + bindingKind: input.bindingKind ?? "tracking", + validFrom: input.validFrom, + validTo: null, + provenanceRef: input.provenanceRef, + ontology: previewOntology("device.asset_binding"), + }; + assetBindings.set(assetBindingRef, assetBinding); + audit(actor, input.projectRef, createdEvent(existing, "asset_binding")); + return { replayed: false, result: { created: !existing, assetBinding } }; + } + if (command === "asset-bindings:close") { + const existing = assetBindings.get(input.assetBindingRef); + if (!existing || existing.projectRef !== input.projectRef || existing.validTo) { + throw serviceError("device_asset_binding_not_closable", 409); + } + const assetBinding = { ...existing, validTo: input.validTo }; + assetBindings.set(existing.assetBindingRef, assetBinding); + audit(actor, input.projectRef, "asset_binding.closed"); + return { replayed: false, result: { closed: true, assetBinding } }; + } + if (command === "infrastructure-hosts:ensure") { + if (!projects.has(input.projectRef)) throw serviceError("device_project_not_found", 404); + const existing = projectValues(hosts, input.projectRef) + .find((item) => item.hostKey === input.hostKey); + const hostRef = existing?.hostRef || `host:${randomUUID()}`; + const host = { + hostRef, + projectRef: input.projectRef, + hostKey: input.hostKey, + displayName: input.displayName, + providerRef: input.providerRef ?? null, + externalRef: input.externalRef ?? null, + managementCredentialConfigured: Boolean(input.managementCredentialRef), + lifecycleState: input.lifecycleState ?? "provisioning", + ontology: previewOntology("infrastructure.host"), + }; + hosts.set(hostRef, host); + audit(actor, input.projectRef, createdEvent(existing, "infrastructure_host")); + return { replayed: false, result: { created: !existing, host } }; + } + if (command === "infrastructure-endpoints:ensure") { + const host = hosts.get(input.hostRef); + if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404); + const existing = projectValues(endpoints, input.projectRef) + .find((item) => item.hostRef === input.hostRef && item.endpointKey === input.endpointKey); + const endpointRef = existing?.endpointRef || `endpoint:${randomUUID()}`; + const endpoint = { + endpointRef, + projectRef: input.projectRef, + hostRef: input.hostRef, + endpointKey: input.endpointKey, + purpose: input.purpose, + endpointUri: input.endpointUri, + lifecycleState: input.lifecycleState ?? "active", + ontology: previewOntology("infrastructure.endpoint"), + }; + endpoints.set(endpointRef, endpoint); + audit(actor, input.projectRef, createdEvent(existing, "infrastructure_endpoint")); + return { replayed: false, result: { created: !existing, endpoint } }; + } + if (command === "infrastructure-deployments:ensure") { + const host = hosts.get(input.hostRef); + if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404); + const existing = projectValues(deployments, input.projectRef) + .find((item) => item.deploymentKey === input.deploymentKey); + const deploymentRef = existing?.deploymentRef || `deployment:${randomUUID()}`; + const deployment = { + deploymentRef, + projectRef: input.projectRef, + hostRef: input.hostRef, + deploymentKey: input.deploymentKey, + displayName: input.displayName, + artifactRef: input.artifactRef, + artifactDigest: input.artifactDigest, + lifecycleState: input.lifecycleState ?? "desired", + ontology: previewOntology("infrastructure.deployment"), + }; + deployments.set(deploymentRef, deployment); + audit(actor, input.projectRef, createdEvent(existing, "infrastructure_deployment")); + return { replayed: false, result: { created: !existing, deployment } }; + } + if (command === "infrastructure-service-instances:ensure") { + const host = hosts.get(input.hostRef); + const deployment = deployments.get(input.deploymentRef); + if (!host || host.projectRef !== input.projectRef) throw serviceError("device_host_not_found", 404); + if (!deployment || deployment.projectRef !== input.projectRef) { + throw serviceError("device_deployment_not_found", 404); + } + const existing = projectValues(serviceInstances, input.projectRef) + .find((item) => item.hostRef === input.hostRef && item.serviceKey === input.serviceKey); + const serviceInstanceRef = existing?.serviceInstanceRef || `service-instance:${randomUUID()}`; + const serviceInstance = { + serviceInstanceRef, + projectRef: input.projectRef, + hostRef: input.hostRef, + deploymentRef: input.deploymentRef, + edgeRef: input.edgeRef ?? null, + serviceKey: input.serviceKey, + displayName: input.displayName, + serviceRole: input.serviceRole, + lifecycleState: input.lifecycleState ?? "provisioning", + ontology: previewOntology("infrastructure.service_instance"), + }; + serviceInstances.set(serviceInstanceRef, serviceInstance); + audit(actor, input.projectRef, createdEvent(existing, "infrastructure_service_instance")); + return { replayed: false, result: { created: !existing, serviceInstance } }; + } + if (command === "health-observations:record") { + const healthObservationRef = `health-observation:${randomUUID()}`; + const healthObservation = { + healthObservationRef, + projectRef: input.projectRef, + subjectKind: input.subjectKind, + subjectRef: input.subjectRef, + observedState: input.observedState, + evidenceClass: input.evidenceClass, + observedAt: input.observedAt, + expiresAt: input.expiresAt, + }; + healthObservations.set(healthObservationRef, healthObservation); + audit(actor, input.projectRef, "health_observation.recorded"); + return { replayed: false, result: { recorded: true, healthObservation } }; + } if (command === "devices:update") { const device = devices.get(input.deviceRef); if (!device || device.projectRef !== input.projectRef) { @@ -658,6 +886,10 @@ function createdEvent(existing, resource) { return `${resource}.${existing ? "updated" : "created"}`; } +function previewOntology(entityId) { + return { entityId, catalogHash: "229c61c02a790906" }; +} + function requirePlatformOwner(actor) { if (actor?.hubRole !== "owner") { throw serviceError("device_platform_catalog_access_denied", 403); @@ -869,6 +1101,7 @@ const ownerCapabilities = Object.freeze([ "project.manage", "access.manage", "inventory.read", + "asset.manage", "device.enroll", "device.claim", "device.transfer", @@ -876,6 +1109,9 @@ const ownerCapabilities = Object.freeze([ "route.manage", "binding.manage", "telemetry.observe", + "observation.write", + "infrastructure.read", + "infrastructure.manage", "configuration.read", "configuration.manage", "command.plan", diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs index 5762d7c..8186419 100644 --- a/apps/device-manager/server/device-core-client.test.mjs +++ b/apps/device-manager/server/device-core-client.test.mjs @@ -60,6 +60,36 @@ test("Device Core client accepts only canonical commands and entity refs", async ); }); +test("Device Core client merges workspace and ontology projections", async () => { + const calls = []; + const client = createDeviceCoreClient({ + baseUrl: "http://127.0.0.1:3210", + token, + fetchImpl: async (url) => { + calls.push(String(url)); + if (String(url).endsWith("/ontology")) { + return jsonResponse(200, { + ok: true, + projection: { + ontology: { catalogHash: "229c61c02a790906", packages: [] }, + assets: [], assetBindings: [], hosts: [], endpoints: [], + deployments: [], serviceInstances: [], policies: {}, + }, + }); + } + return jsonResponse(200, { ok: true, workspace: { project: { projectRef: "project:test" } } }); + }, + }); + const workspace = await client.getWorkspace( + actor, + "project:11111111-1111-4111-8111-111111111111", + ); + assert.equal(workspace.ontology.ontology.catalogHash, "229c61c02a790906"); + assert.equal(calls.length, 2); + assert.ok(calls.some((value) => value.endsWith("/workspace"))); + assert.ok(calls.some((value) => value.endsWith("/ontology"))); +}); + test("local preview is empty and creates resources only through canonical commands", async () => { const client = createLocalPreviewDeviceCore(); assert.deepEqual(await client.listProjects(actor), []); @@ -81,6 +111,47 @@ test("local preview is empty and creates resources only through canonical comman assert.equal(created.result.created, true); const projectRef = created.result.project.projectRef; + const host = await client.execute("infrastructure-hosts:ensure", actor, { + projectRef, + hostKey: "preview-vps", + displayName: "Preview VPS", + providerRef: "provider:preview", + externalRef: "provider-resource:preview-vps", + managementCredentialRef: "secret-ref:device-core/preview-vps", + lifecycleState: "active", + }); + const deployment = await client.execute("infrastructure-deployments:ensure", actor, { + projectRef, + hostRef: host.result.host.hostRef, + deploymentKey: "preview-edge", + displayName: "Preview Edge deployment", + artifactRef: "artifact:device-edge/1.0.0", + artifactDigest: `sha256:${"c".repeat(64)}`, + lifecycleState: "active", + }); + await client.execute("infrastructure-service-instances:ensure", actor, { + projectRef, + hostRef: host.result.host.hostRef, + deploymentRef: deployment.result.deployment.deploymentRef, + edgeRef: null, + serviceKey: "device-edge", + displayName: "Device Edge", + serviceRole: "device.edge", + lifecycleState: "active", + }); + await client.execute("health-observations:record", actor, { + projectRef, + subjectKind: "host", + subjectRef: host.result.host.hostRef, + observedState: "reachable", + evidenceClass: "manual", + sourceRef: "test:preview", + schemaRef: "nodedc.health.test.v1", + evidence: {}, + observedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }); + await client.execute("collections:ensure", actor, { projectRef, collectionKey: "field-devices", @@ -217,6 +288,10 @@ test("local preview is empty and creates resources only through canonical comman assert.equal(workspace.grants.length, 2); assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created")); assert.equal(workspace.policies.commandTransport, "disabled"); + assert.equal(workspace.ontology.hosts[0].health.state, "reachable"); + assert.equal(workspace.ontology.serviceInstances[0].serviceRole, "device.edge"); + assert.equal(workspace.ontology.hosts[0].managementCredentialConfigured, true); + assert.equal(JSON.stringify(workspace).includes("secret-ref:device-core/preview-vps"), false); assert.deepEqual(workspace.devices, []); }); diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs index 2e4e03f..28e91f6 100644 --- a/apps/device-manager/server/device-manager-server.mjs +++ b/apps/device-manager/server/device-manager-server.mjs @@ -29,6 +29,14 @@ const mutationRoutes = new Map([ ["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"], ["/api/device-manager/devices:claim", "devices:claim"], ["/api/device-manager/devices:update", "devices:update"], + ["/api/device-manager/assets:ensure", "assets:ensure"], + ["/api/device-manager/asset-bindings:ensure", "asset-bindings:ensure"], + ["/api/device-manager/asset-bindings:close", "asset-bindings:close"], + ["/api/device-manager/infrastructure-hosts:ensure", "infrastructure-hosts:ensure"], + ["/api/device-manager/infrastructure-endpoints:ensure", "infrastructure-endpoints:ensure"], + ["/api/device-manager/infrastructure-deployments:ensure", "infrastructure-deployments:ensure"], + ["/api/device-manager/infrastructure-service-instances:ensure", "infrastructure-service-instances:ensure"], + ["/api/device-manager/health-observations:record", "health-observations:record"], ["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"], ["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"], [ diff --git a/apps/device-manager/server/device-manager-server.test.mjs b/apps/device-manager/server/device-manager-server.test.mjs index 0753362..f2eaabe 100644 --- a/apps/device-manager/server/device-manager-server.test.mjs +++ b/apps/device-manager/server/device-manager-server.test.mjs @@ -123,6 +123,34 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a deploymentRef: "deployment:preview-edge", lifecycleState: "provisioning", }); + const host = await postJson(`${baseUrl}/api/device-manager/infrastructure-hosts:ensure`, { + projectRef, + hostKey: "preview-vps", + displayName: "Preview VPS", + providerRef: "provider:preview", + externalRef: "provider-resource:preview-vps", + managementCredentialRef: "secret-ref:device-core/preview-vps", + lifecycleState: "active", + }); + const deployment = await postJson(`${baseUrl}/api/device-manager/infrastructure-deployments:ensure`, { + projectRef, + hostRef: host.result.host.hostRef, + deploymentKey: "preview-edge", + displayName: "Preview Edge deployment", + artifactRef: "artifact:device-edge/1.0.0", + artifactDigest: `sha256:${"c".repeat(64)}`, + lifecycleState: "active", + }); + await postJson(`${baseUrl}/api/device-manager/infrastructure-service-instances:ensure`, { + projectRef, + hostRef: host.result.host.hostRef, + deploymentRef: deployment.result.deployment.deploymentRef, + edgeRef: null, + serviceKey: "device-edge", + displayName: "Device Edge", + serviceRole: "device.edge", + lifecycleState: "active", + }); await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, { projectRef, principalKind: "group", @@ -141,6 +169,9 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge"); assert.equal(workspace.workspace.grants.length, 2); assert.equal(workspace.workspace.policies.commandTransport, "disabled"); + assert.equal(workspace.workspace.ontology.hosts[0].hostKey, "preview-vps"); + assert.equal(workspace.workspace.ontology.serviceInstances[0].serviceKey, "device-edge"); + assert.equal(JSON.stringify(workspace).includes("secret-ref:device-core/preview-vps"), false); assert.deepEqual(workspace.workspace.devices, []); const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, { diff --git a/apps/device-manager/src/DeviceControlViews.tsx b/apps/device-manager/src/DeviceControlViews.tsx index a3721ed..b627eec 100644 --- a/apps/device-manager/src/DeviceControlViews.tsx +++ b/apps/device-manager/src/DeviceControlViews.tsx @@ -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" ? ( 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" ? : null} @@ -244,6 +274,55 @@ export function DeviceControlView({ 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 ( Новый VPS Edge : null} + copy={`Канонический ontology catalog ${topology.ontology.catalogHash}: Host, endpoint, deployment и service instance существуют отдельно. Edge — опциональная роль service instance; credentials остаются server-side.`} + actions={canManageInfrastructure ? <> + + + + + : null} /> - - - {workspace.edges.map((edge) => { - const routes = workspace.routes.filter((route) => route.edgeRef === edge.edgeRef); - const runtimeState = edge.channel?.runtimeState ?? "unobserved"; + + + {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 ( Health evidence + ) : null} + /> + ); + })} + + + + + {topology.serviceInstances.map((service) => { + const edge = service.edgeRef + ? workspace.edges.find((item) => item.edgeRef === service.edgeRef) + : null; + return ( + ); })} + + + {topology.deployments.map((deployment) => ( + + ))} + {topology.endpoints.map((endpoint) => ( + + ))} + + + + {canManageAssets ? : null} + {canManageBindings ? : null} + } + /> + + + {topology.assets.map((asset) => { + const activeBindings = topology.assetBindings.filter( + (binding) => binding.assetRef === asset.assetRef && !binding.validTo, + ); + return ( + + ); + })} + + + + + {topology.assetBindings.map((binding) => ( + onCloseAssetBinding(binding)}>Закрыть + ) : formatDate(binding.validTo)} + /> + ))} + +

- Общий реестр произвольных VPS, deployments, services и управляемая консоль требуют отдельного канонического ontology package. Текущий экран намеренно отображает только уже существующую проверяемую Edge-инфраструктуру. + Отсутствующее или просроченное health evidence отображается как unobserved, а не unreachable. Arbitrary WebSSH console отключена; будущая консоль потребует отдельной короткоживущей management session и break-glass аудита.

@@ -753,6 +953,226 @@ function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions ; } +function AssetDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) { + const [assetKey, setAssetKey] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [assetTypeRef, setAssetTypeRef] = useState("asset-type:delivery-trike"); + return { + await ensureAsset({ + projectRef, + assetKey, + displayName, + assetTypeRef, + lifecycleState: "active", + }); + }}> + + setDisplayName(event.target.value)} required /> + setAssetTypeRef(event.target.value)} required description="Канонический тип или стабильная ссылка на тип, не модель трекера." /> + ; +} + +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 { + await ensureAssetBinding({ + projectRef: workspace.project.projectRef, + bindingKey, + deviceRef, + assetRef, + bindingKind, + validFrom: new Date().toISOString(), + provenanceRef, + }); + }}> + ({ value: item.assetRef, label: item.displayName, description: item.assetTypeRef }))} /> + + ({ value: item.hostRef, label: item.displayName }))} /> + + ({ value: item.hostRef, label: item.displayName }))} /> + + setDisplayName(event.target.value)} required /> + setArtifactRef(event.target.value)} required placeholder="artifact:device-edge/1.0.0" /> + setArtifactDigest(event.target.value)} required placeholder="sha256:…" /> + ; +} + +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 { + await ensureInfrastructureServiceInstance({ + projectRef: workspace.project.projectRef, + hostRef, + deploymentRef, + edgeRef: edgeRef || null, + serviceKey, + displayName, + serviceRole, + lifecycleState: "active", + }); + }}> + ({ value: item.deploymentRef, label: item.displayName }))} /> + +