feat(manager): manage ontology assets and infrastructure
This commit is contained in:
@@ -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`, {
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user