feat(manager): manage ontology assets and infrastructure

This commit is contained in:
DCCONSTRUCTIONS
2026-08-22 15:04:32 +03:00
parent b302b6ba1a
commit 088686d67f
8 changed files with 1027 additions and 27 deletions
+11 -2
View File
@@ -20,8 +20,17 @@ behavior; projects, inventory, collections and access remain shared Device Core
The project workspace covers inventory, discovery, collections, adapter/profile metadata, The project workspace covers inventory, discovery, collections, adapter/profile metadata,
Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable
audit metadata and project grants. Navigation and actions are derived from effective project audit metadata and project grants. It also joins the official ontology projection for stable
capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner. 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. Command planning and transport intentionally have no Device Manager mutation route yet.
The UI never presents `sent` as success: `acknowledged` and `verified` remain different The UI never presents `sent` as success: `acknowledged` and `verified` remain different
@@ -13,6 +13,14 @@ const commandRoutes = new Map([
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"], ["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
["devices:claim", "/internal/v1/management/devices:claim"], ["devices:claim", "/internal/v1/management/devices:claim"],
["devices:update", "/internal/v1/management/devices:update"], ["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:ensure", "/internal/v1/management/device-bindings:ensure"],
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"], ["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
[ [
@@ -61,8 +69,13 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {
}, },
async getWorkspace(actor, projectRef) { async getWorkspace(actor, projectRef) {
const projectId = entityId(projectRef, "project"); const projectId = entityId(projectRef, "project");
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor) const [workspace, ontology] = await Promise.all([
.then((body) => body.workspace); 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) { async execute(command, actor, input, idempotencyKey) {
const pathname = commandRoutes.get(command); const pathname = commandRoutes.get(command);
@@ -100,6 +113,13 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
const configurationStates = new Map(); const configurationStates = new Map();
const auditEvents = []; const auditEvents = [];
const commands = new Map(); 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() { function now() {
return new Date().toISOString(); return new Date().toISOString();
@@ -166,6 +186,53 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only", identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
auditPayloadProjection: "metadata-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") { if (command === "devices:claim") {
throw serviceError("device_discovery_not_found", 404); 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") { if (command === "devices:update") {
const device = devices.get(input.deviceRef); const device = devices.get(input.deviceRef);
if (!device || device.projectRef !== input.projectRef) { if (!device || device.projectRef !== input.projectRef) {
@@ -658,6 +886,10 @@ function createdEvent(existing, resource) {
return `${resource}.${existing ? "updated" : "created"}`; return `${resource}.${existing ? "updated" : "created"}`;
} }
function previewOntology(entityId) {
return { entityId, catalogHash: "229c61c02a790906" };
}
function requirePlatformOwner(actor) { function requirePlatformOwner(actor) {
if (actor?.hubRole !== "owner") { if (actor?.hubRole !== "owner") {
throw serviceError("device_platform_catalog_access_denied", 403); throw serviceError("device_platform_catalog_access_denied", 403);
@@ -869,6 +1101,7 @@ const ownerCapabilities = Object.freeze([
"project.manage", "project.manage",
"access.manage", "access.manage",
"inventory.read", "inventory.read",
"asset.manage",
"device.enroll", "device.enroll",
"device.claim", "device.claim",
"device.transfer", "device.transfer",
@@ -876,6 +1109,9 @@ const ownerCapabilities = Object.freeze([
"route.manage", "route.manage",
"binding.manage", "binding.manage",
"telemetry.observe", "telemetry.observe",
"observation.write",
"infrastructure.read",
"infrastructure.manage",
"configuration.read", "configuration.read",
"configuration.manage", "configuration.manage",
"command.plan", "command.plan",
@@ -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 () => { test("local preview is empty and creates resources only through canonical commands", async () => {
const client = createLocalPreviewDeviceCore(); const client = createLocalPreviewDeviceCore();
assert.deepEqual(await client.listProjects(actor), []); 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); assert.equal(created.result.created, true);
const projectRef = created.result.project.projectRef; 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, { await client.execute("collections:ensure", actor, {
projectRef, projectRef,
collectionKey: "field-devices", 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.equal(workspace.grants.length, 2);
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created")); assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
assert.equal(workspace.policies.commandTransport, "disabled"); 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, []); assert.deepEqual(workspace.devices, []);
}); });
@@ -29,6 +29,14 @@ const mutationRoutes = new Map([
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"], ["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
["/api/device-manager/devices:claim", "devices:claim"], ["/api/device-manager/devices:claim", "devices:claim"],
["/api/device-manager/devices:update", "devices:update"], ["/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:ensure", "device-bindings:ensure"],
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"], ["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
[ [
@@ -123,6 +123,34 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a
deploymentRef: "deployment:preview-edge", deploymentRef: "deployment:preview-edge",
lifecycleState: "provisioning", 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`, { await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
projectRef, projectRef,
principalKind: "group", 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.edges[0].edgeKey, "preview-edge");
assert.equal(workspace.workspace.grants.length, 2); assert.equal(workspace.workspace.grants.length, 2);
assert.equal(workspace.workspace.policies.commandTransport, "disabled"); 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, []); assert.deepEqual(workspace.workspace.devices, []);
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, { const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
+443 -23
View File
@@ -14,12 +14,20 @@ import {
import { import {
createConfigurationRevision, createConfigurationRevision,
closeAssetBinding,
ensureAsset,
ensureAssetBinding,
ensureAdapterPackage, ensureAdapterPackage,
ensureDeviceBinding, ensureDeviceBinding,
ensureEdge, ensureEdge,
ensureInfrastructureDeployment,
ensureInfrastructureEndpoint,
ensureInfrastructureHost,
ensureInfrastructureServiceInstance,
ensureRoute, ensureRoute,
registerAdapterVersion, registerAdapterVersion,
registerModelProfile, registerModelProfile,
recordHealthObservation,
revokeDeviceBinding, revokeDeviceBinding,
sendServicePing, sendServicePing,
setDesiredConfiguration, setDesiredConfiguration,
@@ -28,9 +36,11 @@ import {
import type { import type {
AdapterPackageView, AdapterPackageView,
AdapterVersionView, AdapterVersionView,
AssetBindingView,
BindingView, BindingView,
DeviceManagerSession, DeviceManagerSession,
EdgeView, EdgeView,
InfrastructureHostView,
ModelProfileView, ModelProfileView,
ProjectWorkspace, ProjectWorkspace,
} from "./types"; } from "./types";
@@ -55,6 +65,13 @@ type DialogId =
| "binding" | "binding"
| "grant" | "grant"
| "configuration" | "configuration"
| "asset"
| "asset-binding"
| "host"
| "endpoint"
| "deployment"
| "service-instance"
| "health-observation"
| null; | null;
export function DeviceControlView({ export function DeviceControlView({
@@ -149,8 +166,21 @@ export function DeviceControlView({
{view === "hosts" ? ( {view === "hosts" ? (
<HostsView <HostsView
workspace={workspace} workspace={workspace}
canManage={platformOwner} canManageInfrastructure={capabilities.has("infrastructure.manage")}
onCreateEdge={() => setDialog("edge")} 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} ) : null}
{view === "sessions" ? <SessionsView workspace={workspace} /> : null} {view === "sessions" ? <SessionsView workspace={workspace} /> : null}
@@ -244,6 +274,55 @@ export function DeviceControlView({
onCreated={completed} onCreated={completed}
onError={onError} 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; workspace: ProjectWorkspace;
canManage: boolean; canManageInfrastructure: boolean;
onCreateEdge: () => void; 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 ( return (
<ControlStack> <ControlStack>
<ControlToolbar <ControlToolbar
copy="VPS-хосты показаны через зарегистрированную роль Edge: состояние берётся из pinned mTLS Core↔Edge канала, а связи — из маршрутов проекта. Адреса, ключи и credentials в браузер не выдаются." copy={`Канонический ontology catalog ${topology.ontology.catalogHash}: Host, endpoint, deployment и service instance существуют отдельно. Edge — опциональная роль service instance; credentials остаются server-side.`}
actions={canManage ? <Button size="compact" variant="primary" onClick={onCreateEdge}>Новый VPS Edge</Button> : null} 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}> <ControlSection title="VPS и хосты" count={topology.hosts.length}>
<ResourceGrid empty="VPS/Edge-хосты для проекта пока не зарегистрированы."> <ResourceGrid empty="VPS и хосты для проекта пока не зарегистрированы.">
{workspace.edges.map((edge) => { {topology.hosts.map((host) => {
const routes = workspace.routes.filter((route) => route.edgeRef === edge.edgeRef); const hostEndpoints = topology.endpoints.filter((item) => item.hostRef === host.hostRef);
const runtimeState = edge.channel?.runtimeState ?? "unobserved"; const hostServices = topology.serviceInstances.filter((item) => item.hostRef === host.hostRef);
return ( return (
<ResourceCard <ResourceCard
key={edge.edgeRef} key={host.hostRef}
eyebrow="VPS / EDGE HOST" eyebrow="INFRASTRUCTURE / HOST"
title={edge.displayName} title={host.displayName}
description={edge.deploymentRef || edge.edgeKey} description={host.externalRef || host.hostKey}
status={runtimeState} status={host.health.state}
meta={[ meta={[
`registration · ${edge.lifecycleState}`, `lifecycle · ${host.lifecycleState}`,
`channel · ${edge.channel?.lifecycleState ?? "disabled"}`, `health · ${host.health.freshness}`,
`${routes.length} ${routes.length === 1 ? "маршрут" : "маршрутов"}`, ...(host.providerRef ? [`provider · ${host.providerRef}`] : []),
...(edge.channel?.generationRef ? [edge.channel.generationRef] : []), `${hostEndpoints.length} endpoints · ${hostServices.length} services`,
...(edge.channel?.lastErrorCode ? [`error · ${edge.channel.lastErrorCode}`] : []), `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> </ResourceGrid>
</ControlSection> </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"> <GlassSurface padding="md" tone="soft">
<p className="device-manager-card-copy"> <p className="device-manager-card-copy">
Общий реестр произвольных VPS, deployments, services и управляемая консоль требуют отдельного канонического ontology package. Текущий экран намеренно отображает только уже существующую проверяемую Edge-инфраструктуру. Отсутствующее или просроченное health evidence отображается как unobserved, а не unreachable. Arbitrary WebSSH console отключена; будущая консоль потребует отдельной короткоживущей management session и break-glass аудита.
</p> </p>
</GlassSurface> </GlassSurface>
</ControlStack> </ControlStack>
@@ -753,6 +953,226 @@ function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions
</FormWindow>; </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) { function EdgeDialog(props: DialogBaseProps) {
const [edgeKey, setEdgeKey] = useState(""); const [edgeKey, setEdgeKey] = useState("");
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
+120
View File
@@ -1,10 +1,16 @@
import type { import type {
AdapterPackageView, AdapterPackageView,
AdapterVersionView, AdapterVersionView,
AssetBindingView,
AssetView,
BindingView, BindingView,
ConfigurationRevisionView, ConfigurationRevisionView,
EdgeView, EdgeView,
ModelProfileView, ModelProfileView,
InfrastructureDeploymentView,
InfrastructureEndpointView,
InfrastructureHostView,
InfrastructureServiceInstanceView,
ProjectGrantView, ProjectGrantView,
DeviceManagerSession, DeviceManagerSession,
DeviceManagerPresentation, DeviceManagerPresentation,
@@ -144,6 +150,120 @@ export async function ensureEnrollmentIntent(input: {
return mutate("/api/device-manager/enrollment-intents:ensure", input); return mutate("/api/device-manager/enrollment-intents:ensure", input);
} }
export async function ensureAsset(input: {
projectRef: string;
assetKey: string;
displayName: string;
assetTypeRef: string;
lifecycleState?: "active" | "retired";
}) {
return mutate<{ created: boolean; asset: AssetView }>(
"/api/device-manager/assets:ensure",
input,
);
}
export async function ensureAssetBinding(input: {
projectRef: string;
bindingKey: string;
deviceRef: string;
assetRef: string;
bindingKind: "tracking" | "installed" | "assigned";
validFrom: string;
provenanceRef: string;
}) {
return mutate<{ created: boolean; assetBinding: AssetBindingView }>(
"/api/device-manager/asset-bindings:ensure",
input,
);
}
export async function closeAssetBinding(input: {
projectRef: string;
assetBindingRef: string;
validTo: string;
}) {
return mutate<{ closed: boolean; assetBinding: AssetBindingView }>(
"/api/device-manager/asset-bindings:close",
input,
);
}
export async function ensureInfrastructureHost(input: {
projectRef: string;
hostKey: string;
displayName: string;
providerRef: string | null;
externalRef: string | null;
managementCredentialRef: string | null;
lifecycleState: "provisioning" | "active" | "suspended" | "retired";
}) {
return mutate<{ created: boolean; host: InfrastructureHostView }>(
"/api/device-manager/infrastructure-hosts:ensure",
input,
);
}
export async function ensureInfrastructureEndpoint(input: {
projectRef: string;
hostRef: string;
endpointKey: string;
purpose: "management" | "service" | "monitoring";
endpointUri: string;
lifecycleState: "active" | "disabled" | "retired";
}) {
return mutate<{ created: boolean; endpoint: InfrastructureEndpointView }>(
"/api/device-manager/infrastructure-endpoints:ensure",
input,
);
}
export async function ensureInfrastructureDeployment(input: {
projectRef: string;
hostRef: string;
deploymentKey: string;
displayName: string;
artifactRef: string;
artifactDigest: string;
lifecycleState: "desired" | "applying" | "active" | "failed" | "retired";
}) {
return mutate<{ created: boolean; deployment: InfrastructureDeploymentView }>(
"/api/device-manager/infrastructure-deployments:ensure",
input,
);
}
export async function ensureInfrastructureServiceInstance(input: {
projectRef: string;
hostRef: string;
deploymentRef: string;
edgeRef: string | null;
serviceKey: string;
displayName: string;
serviceRole: string;
lifecycleState: "provisioning" | "active" | "degraded" | "stopped" | "retired";
}) {
return mutate<{ created: boolean; serviceInstance: InfrastructureServiceInstanceView }>(
"/api/device-manager/infrastructure-service-instances:ensure",
input,
);
}
export async function recordHealthObservation(input: {
projectRef: string;
subjectKind: "host" | "service-instance";
subjectRef: string;
observedState: "reachable" | "degraded" | "unreachable";
evidenceClass: "agent_probe" | "channel" | "management_probe" | "manual";
sourceRef: string;
schemaRef: string;
evidence: Record<string, unknown>;
observedAt: string;
expiresAt: string;
}) {
return mutate("/api/device-manager/health-observations:record", input);
}
export async function upsertProjectGrant(input: { export async function upsertProjectGrant(input: {
projectRef: string; projectRef: string;
principalKind: "user" | "group"; principalKind: "user" | "group";
+101
View File
@@ -311,6 +311,106 @@ export interface ProjectGrantView {
lifecycleState: string; lifecycleState: string;
} }
export interface OntologyRefView {
entityId: string;
catalogHash: string;
}
export interface AssetView {
assetRef: string;
assetKey: string;
displayName: string;
assetTypeRef: string;
lifecycleState: string;
ontology: OntologyRefView;
}
export interface AssetBindingView {
assetBindingRef: string;
bindingKey: string;
deviceRef: string;
deviceName: string;
assetRef: string;
assetName: string;
bindingKind: string;
validFrom: string;
validTo: string | null;
provenanceRef: string;
ontology: OntologyRefView;
}
export interface HealthProjectionView {
state: string;
freshness: "fresh" | "stale" | "missing";
lastObservedState?: string;
evidenceClass?: string;
observedAt?: string | null;
expiresAt?: string | null;
observationRef: string | null;
}
export interface InfrastructureHostView {
hostRef: string;
hostKey: string;
displayName: string;
providerRef: string | null;
externalRef: string | null;
managementCredentialConfigured: boolean;
lifecycleState: string;
health: HealthProjectionView;
ontology: OntologyRefView;
}
export interface InfrastructureEndpointView {
endpointRef: string;
hostRef: string;
endpointKey: string;
purpose: "management" | "service" | "monitoring";
endpointUri: string;
lifecycleState: string;
ontology: OntologyRefView;
}
export interface InfrastructureDeploymentView {
deploymentRef: string;
hostRef: string;
deploymentKey: string;
displayName: string;
artifactRef: string;
artifactDigest: string;
lifecycleState: string;
ontology: OntologyRefView;
}
export interface InfrastructureServiceInstanceView {
serviceInstanceRef: string;
hostRef: string;
deploymentRef: string;
edgeRef: string | null;
serviceKey: string;
displayName: string;
serviceRole: string;
lifecycleState: string;
health: HealthProjectionView;
ontology: OntologyRefView;
}
export interface ProjectOntologyProjection {
ontology: { catalogHash: string; packages: string[] };
assets: AssetView[];
assetBindings: AssetBindingView[];
hosts: InfrastructureHostView[];
endpoints: InfrastructureEndpointView[];
deployments: InfrastructureDeploymentView[];
serviceInstances: InfrastructureServiceInstanceView[];
policies: {
restrictedIdentifiers: string;
managementCredentials: string;
missingHealthEvidence: string;
arbitraryConsole: string;
};
}
export interface ProjectWorkspace { export interface ProjectWorkspace {
project: ProjectSummary; project: ProjectSummary;
devices: DeviceView[]; devices: DeviceView[];
@@ -329,6 +429,7 @@ export interface ProjectWorkspace {
commands: CommandView[]; commands: CommandView[];
auditEvents: AuditEventView[]; auditEvents: AuditEventView[];
grants: ProjectGrantView[]; grants: ProjectGrantView[];
ontology: ProjectOntologyProjection;
policies: { policies: {
commandTransport: "disabled" | "typed-service-ping-v1"; commandTransport: "disabled" | "typed-service-ping-v1";
commandPlanningApi: "disabled" | "enabled"; commandPlanningApi: "disabled" | "enabled";