feat(device-manager): add control and audit workspaces
This commit is contained in:
@@ -14,8 +14,18 @@ behavior; projects, inventory, collections and access remain shared Device Core
|
||||
- The BFF reads the Core bearer token from `NODEDC_DEVICE_CORE_TOKEN_FILE`; the token is
|
||||
never embedded into client assets or accepted as a raw environment value.
|
||||
- Device Control Core owns authorization, lifecycle validation, idempotency and persistence.
|
||||
- Query responses contain masked identifiers only. Digests and credential references stay
|
||||
inside Device Control Core.
|
||||
- Query responses contain masked identifiers and bounded metadata only. Identifier and
|
||||
credential digests, external approval proofs, command parameters/transport refs, raw
|
||||
configuration documents and audit payloads stay inside Device Control Core.
|
||||
|
||||
The project workspace covers inventory, discovery, collections, adapter/profile metadata,
|
||||
Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable
|
||||
audit metadata and project grants. Navigation and actions are derived from effective project
|
||||
capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner.
|
||||
|
||||
Command planning and transport intentionally have no Device Manager mutation route yet.
|
||||
The UI never presents `sent` as success: `acknowledged` and `verified` remain different
|
||||
ledger states, and the disabled transport policy is visible in the Commands section.
|
||||
|
||||
Hub currently supplies identity and groups but no signed company-membership/owner-scope
|
||||
claim. Therefore an admin may create projects in their personal scope. Existing company
|
||||
|
||||
@@ -4,7 +4,24 @@ const commandRoutes = new Map([
|
||||
["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"],
|
||||
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
||||
["collections:ensure", "/internal/v1/management/collections:ensure"],
|
||||
["project-grants:upsert", "/internal/v1/management/project-grants:upsert"],
|
||||
["adapter-packages:ensure", "/internal/v1/management/adapter-packages:ensure"],
|
||||
["adapter-versions:register", "/internal/v1/management/adapter-versions:register"],
|
||||
["model-profiles:register", "/internal/v1/management/model-profiles:register"],
|
||||
["edges:ensure", "/internal/v1/management/edges:ensure"],
|
||||
["routes:ensure", "/internal/v1/management/routes:ensure"],
|
||||
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
|
||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
||||
["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"],
|
||||
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
||||
[
|
||||
"device-configuration-revisions:create",
|
||||
"/internal/v1/management/device-configuration-revisions:create",
|
||||
],
|
||||
[
|
||||
"device-configurations:set-desired",
|
||||
"/internal/v1/management/device-configurations:set-desired",
|
||||
],
|
||||
]);
|
||||
|
||||
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
||||
@@ -67,6 +84,36 @@ export function createLocalPreviewDeviceCore() {
|
||||
const ownerScopes = new Map();
|
||||
const projects = new Map();
|
||||
const collections = new Map();
|
||||
const adapterPackages = new Map();
|
||||
const adapterVersions = new Map();
|
||||
const modelProfiles = new Map();
|
||||
const edges = new Map();
|
||||
const routes = new Map();
|
||||
const bindings = new Map();
|
||||
const grants = new Map();
|
||||
const configurationRevisions = new Map();
|
||||
const configurationStates = new Map();
|
||||
const auditEvents = [];
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function projectValues(store, projectRef) {
|
||||
return [...store.values()].filter((value) => value.projectRef === projectRef);
|
||||
}
|
||||
|
||||
function audit(actor, projectRef, eventType, refs = {}) {
|
||||
auditEvents.unshift({
|
||||
auditEventRef: `audit-event:${randomUUID()}`,
|
||||
eventType,
|
||||
actorRef: actor.userRef,
|
||||
deviceRef: refs.deviceRef ?? null,
|
||||
discoveryRef: refs.discoveryRef ?? null,
|
||||
projectRef,
|
||||
occurredAt: now(),
|
||||
});
|
||||
}
|
||||
|
||||
function projectSummary(project) {
|
||||
const projectCollections = [...collections.values()]
|
||||
@@ -77,23 +124,44 @@ export function createLocalPreviewDeviceCore() {
|
||||
};
|
||||
}
|
||||
|
||||
function workspace(projectRef) {
|
||||
const project = projects.get(projectRef);
|
||||
if (!project) throw serviceError("device_project_not_found", 404);
|
||||
return {
|
||||
project: projectSummary(project),
|
||||
devices: [],
|
||||
discoveries: [],
|
||||
enrollments: [],
|
||||
collections: projectValues(collections, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
||||
adapterPackages: [...adapterPackages.values()],
|
||||
adapterVersions: [...adapterVersions.values()],
|
||||
modelProfiles: [...modelProfiles.values()],
|
||||
edges: [...edges.values()],
|
||||
routes: projectValues(routes, projectRef),
|
||||
sessions: [],
|
||||
bindings: projectValues(bindings, projectRef),
|
||||
configurationRevisions: projectValues(configurationRevisions, projectRef),
|
||||
configurationStates: projectValues(configurationStates, projectRef),
|
||||
commands: [],
|
||||
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
|
||||
grants: projectValues(grants, projectRef),
|
||||
policies: {
|
||||
commandTransport: "disabled",
|
||||
commandPlanningApi: "disabled",
|
||||
identifierProjection: "masked-only",
|
||||
auditPayloadProjection: "metadata-only",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
async listProjects() {
|
||||
return [...projects.values()].map(projectSummary);
|
||||
},
|
||||
async getWorkspace(_actor, projectRef) {
|
||||
const project = projects.get(projectRef);
|
||||
if (!project) throw serviceError("device_project_not_found", 404);
|
||||
return {
|
||||
project: projectSummary(project),
|
||||
devices: [],
|
||||
discoveries: [],
|
||||
enrollments: [],
|
||||
collections: [...collections.values()]
|
||||
.filter((collection) => collection.projectRef === projectRef)
|
||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
||||
};
|
||||
return workspace(projectRef);
|
||||
},
|
||||
async execute(command, actor, input) {
|
||||
if (command === "owner-scopes:ensure") {
|
||||
@@ -130,6 +198,20 @@ export function createLocalPreviewDeviceCore() {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
projects.set(projectRef, project);
|
||||
if (!existing) {
|
||||
const grantRef = `grant:${randomUUID()}`;
|
||||
grants.set(grantRef, {
|
||||
grantRef,
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: actor.userRef,
|
||||
projectRole: "owner",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
audit(actor, projectRef, "project.created");
|
||||
}
|
||||
return { replayed: false, result: { created: !existing, project } };
|
||||
}
|
||||
if (command === "collections:ensure") {
|
||||
@@ -152,19 +234,241 @@ export function createLocalPreviewDeviceCore() {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
collections.set(collectionRef, collection);
|
||||
audit(actor, projectRef, createdEvent(existing, "collection"));
|
||||
return { replayed: false, result: { created: !existing, collection } };
|
||||
}
|
||||
if (command === "project-grants:upsert") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const existing = [...grants.values()].find((grant) =>
|
||||
grant.projectRef === input.projectRef
|
||||
&& grant.principalKind === input.principalKind
|
||||
&& grant.principalRef === input.principalRef
|
||||
);
|
||||
const grantRef = existing?.grantRef || `grant:${randomUUID()}`;
|
||||
const grant = {
|
||||
grantRef,
|
||||
projectRef: input.projectRef,
|
||||
principalKind: input.principalKind,
|
||||
principalRef: input.principalRef,
|
||||
projectRole: input.projectRole,
|
||||
capabilityAllow: input.capabilityAllow ?? [],
|
||||
capabilityDeny: input.capabilityDeny ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
};
|
||||
grants.set(grantRef, grant);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "project_grant"));
|
||||
return { replayed: false, result: { created: !existing, grant } };
|
||||
}
|
||||
if (command === "adapter-packages:ensure") {
|
||||
requirePlatformOwner(actor);
|
||||
const existing = [...adapterPackages.values()].find(
|
||||
(entry) => entry.packageKey === input.packageKey,
|
||||
);
|
||||
const adapterPackageRef = existing?.adapterPackageRef
|
||||
|| `adapter-package:${randomUUID()}`;
|
||||
const adapterPackage = {
|
||||
adapterPackageRef,
|
||||
packageKey: input.packageKey,
|
||||
displayName: input.displayName,
|
||||
publisherRef: input.publisherRef,
|
||||
lifecycleState: input.lifecycleState ?? "active",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
adapterPackages.set(adapterPackageRef, adapterPackage);
|
||||
return { replayed: false, result: { created: !existing, adapterPackage } };
|
||||
}
|
||||
if (command === "adapter-versions:register") {
|
||||
requirePlatformOwner(actor);
|
||||
if (!adapterPackages.has(input.adapterPackageRef)) {
|
||||
throw serviceError("device_adapter_package_not_found", 404);
|
||||
}
|
||||
const existing = [...adapterVersions.values()].find((entry) =>
|
||||
entry.adapterPackageRef === input.adapterPackageRef
|
||||
&& entry.version === input.version
|
||||
);
|
||||
const adapterVersionRef = existing?.adapterVersionRef
|
||||
|| `adapter-version:${randomUUID()}`;
|
||||
const adapterVersion = {
|
||||
adapterVersionRef,
|
||||
adapterPackageRef: input.adapterPackageRef,
|
||||
version: input.version,
|
||||
runtimePackageRef: input.runtimePackageRef,
|
||||
contentDigest: input.contentDigest,
|
||||
contractVersion: input.contractVersion,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
adapterVersions.set(adapterVersionRef, adapterVersion);
|
||||
return { replayed: false, result: { created: !existing, adapterVersion } };
|
||||
}
|
||||
if (command === "model-profiles:register") {
|
||||
requirePlatformOwner(actor);
|
||||
if (!adapterVersions.has(input.adapterVersionRef)) {
|
||||
throw serviceError("device_adapter_version_not_found", 404);
|
||||
}
|
||||
const existing = modelProfiles.get(input.profileRef);
|
||||
const modelProfile = {
|
||||
modelProfileRef: input.profileRef,
|
||||
adapterVersionRef: input.adapterVersionRef,
|
||||
schemaVersion: input.schemaVersion,
|
||||
vendor: input.vendor,
|
||||
model: input.model,
|
||||
deviceType: input.deviceType,
|
||||
protocol: input.protocol,
|
||||
schemaArtifactRef: input.schemaArtifactRef,
|
||||
profileDigest: input.profileDigest,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
modelProfiles.set(input.profileRef, modelProfile);
|
||||
return { replayed: false, result: { created: !existing, modelProfile } };
|
||||
}
|
||||
if (command === "edges:ensure") {
|
||||
requirePlatformOwner(actor);
|
||||
const existing = [...edges.values()].find(
|
||||
(entry) => entry.edgeKey === input.edgeKey,
|
||||
);
|
||||
const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`;
|
||||
const edge = {
|
||||
edgeRef,
|
||||
edgeKey: input.edgeKey,
|
||||
displayName: input.displayName,
|
||||
deploymentRef: input.deploymentRef ?? null,
|
||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
edges.set(edgeRef, edge);
|
||||
return { replayed: false, result: { created: !existing, edge } };
|
||||
}
|
||||
if (command === "routes:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
const edge = edges.get(input.edgeRef);
|
||||
const profile = modelProfiles.get(input.modelProfileRef);
|
||||
if (!edge) throw serviceError("device_edge_not_found", 404);
|
||||
if (!profile) throw serviceError("device_model_profile_not_found", 404);
|
||||
const existing = projectValues(routes, input.projectRef).find(
|
||||
(entry) => entry.routeKey === input.routeKey,
|
||||
);
|
||||
const routeRef = existing?.routeRef || `route:${randomUUID()}`;
|
||||
const route = {
|
||||
routeRef,
|
||||
projectRef: input.projectRef,
|
||||
routeKey: input.routeKey,
|
||||
displayName: input.displayName,
|
||||
edgeRef: input.edgeRef,
|
||||
edgeName: edge.displayName,
|
||||
modelProfileRef: input.modelProfileRef,
|
||||
profileName: `${profile.vendor} ${profile.model}`,
|
||||
listenerRef: input.listenerRef,
|
||||
protocol: input.protocol,
|
||||
direction: input.direction ?? "telemetry",
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
sessionCount: 0,
|
||||
activeSessionCount: 0,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
routes.set(routeRef, route);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "route"));
|
||||
return { replayed: false, result: { created: !existing, route } };
|
||||
}
|
||||
if (command === "device-bindings:ensure") {
|
||||
if (!projects.has(input.projectRef)) {
|
||||
throw serviceError("device_project_not_found", 404);
|
||||
}
|
||||
if (input.source?.kind !== "collection" || !collections.has(input.source.ref)) {
|
||||
throw serviceError("device_binding_source_not_found", 404);
|
||||
}
|
||||
const existing = projectValues(bindings, input.projectRef).find(
|
||||
(entry) => entry.bindingKey === input.bindingKey,
|
||||
);
|
||||
const bindingRef = existing?.bindingRef || `binding:${randomUUID()}`;
|
||||
const source = collections.get(input.source.ref);
|
||||
const binding = {
|
||||
bindingRef,
|
||||
projectRef: input.projectRef,
|
||||
bindingKey: input.bindingKey,
|
||||
displayName: input.displayName,
|
||||
source: {
|
||||
kind: input.source.kind,
|
||||
ref: input.source.ref,
|
||||
displayName: source.name,
|
||||
},
|
||||
target: { kind: input.targetKind, ref: input.targetRef },
|
||||
capabilities: input.capabilities,
|
||||
lifecycleState: "pending_external_approval",
|
||||
sourceApprovedAt: now(),
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
bindings.set(bindingRef, binding);
|
||||
audit(actor, input.projectRef, createdEvent(existing, "device_binding"));
|
||||
return { replayed: false, result: { created: !existing, binding } };
|
||||
}
|
||||
if (command === "device-bindings:revoke") {
|
||||
const binding = bindings.get(input.bindingRef);
|
||||
if (!binding || binding.projectRef !== input.projectRef) {
|
||||
throw serviceError("device_binding_not_found", 404);
|
||||
}
|
||||
const revoked = { ...binding, lifecycleState: "revoked", updatedAt: now() };
|
||||
bindings.set(binding.bindingRef, revoked);
|
||||
audit(actor, input.projectRef, "device_binding.revoked");
|
||||
return { replayed: false, result: { revoked: true, binding: revoked } };
|
||||
}
|
||||
if (command === "device-configuration-revisions:create") {
|
||||
throw serviceError("device_not_found", 404);
|
||||
}
|
||||
if (command === "device-configurations:set-desired") {
|
||||
throw serviceError("device_configuration_revision_not_found", 404);
|
||||
}
|
||||
if (command === "enrollment-intents:ensure") {
|
||||
throw serviceError("device_enrollment_secure_input_required", 409);
|
||||
}
|
||||
if (command === "devices:claim") {
|
||||
throw serviceError("device_discovery_not_found", 404);
|
||||
}
|
||||
throw serviceError("device_manager_command_invalid", 404);
|
||||
},
|
||||
snapshot() {
|
||||
return { ownerScopes, projects, collections };
|
||||
return {
|
||||
ownerScopes,
|
||||
projects,
|
||||
collections,
|
||||
adapterPackages,
|
||||
adapterVersions,
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
bindings,
|
||||
grants,
|
||||
configurationRevisions,
|
||||
configurationStates,
|
||||
auditEvents,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createdEvent(existing, resource) {
|
||||
return `${resource}.${existing ? "updated" : "created"}`;
|
||||
}
|
||||
|
||||
function requirePlatformOwner(actor) {
|
||||
if (actor?.hubRole !== "owner") {
|
||||
throw serviceError("device_platform_catalog_access_denied", 403);
|
||||
}
|
||||
}
|
||||
|
||||
const ownerCapabilities = Object.freeze([
|
||||
"project.read",
|
||||
"project.manage",
|
||||
|
||||
@@ -13,6 +13,7 @@ const actor = Object.freeze({
|
||||
groupRefs: ["group:device-engineers"],
|
||||
ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }],
|
||||
});
|
||||
const platformActor = Object.freeze({ ...actor, hubRole: "owner" });
|
||||
|
||||
test("Device Core client creates trusted actor headers and keeps its token server-side", async () => {
|
||||
const calls = [];
|
||||
@@ -87,11 +88,83 @@ test("local preview is empty and creates resources only through canonical comman
|
||||
description: null,
|
||||
});
|
||||
|
||||
const adapterPackage = await client.execute("adapter-packages:ensure", platformActor, {
|
||||
packageKey: "generic-tracker",
|
||||
displayName: "Generic tracker",
|
||||
publisherRef: "publisher:nodedc",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const adapterVersion = await client.execute("adapter-versions:register", platformActor, {
|
||||
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
|
||||
version: "1.0.0",
|
||||
runtimePackageRef: "artifact:generic-tracker:1.0.0",
|
||||
contentDigest: `sha256:${"a".repeat(64)}`,
|
||||
contractVersion: "device-adapter.v1",
|
||||
capabilities: ["telemetry"],
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
const modelProfile = await client.execute("model-profiles:register", platformActor, {
|
||||
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
|
||||
profileRef: "generic.tracker.v1",
|
||||
schemaVersion: "1.0.0",
|
||||
vendor: "Generic",
|
||||
model: "Tracker",
|
||||
deviceType: "tracker",
|
||||
protocol: "INTERNAL",
|
||||
schemaArtifactRef: "schema:generic.tracker.v1",
|
||||
profileDigest: `sha256:${"b".repeat(64)}`,
|
||||
capabilities: ["telemetry"],
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
const edge = await client.execute("edges:ensure", platformActor, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
await client.execute("routes:ensure", actor, {
|
||||
projectRef,
|
||||
routeKey: "preview-route",
|
||||
displayName: "Preview route",
|
||||
edgeRef: edge.result.edge.edgeRef,
|
||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
||||
listenerRef: "listener:preview",
|
||||
protocol: "INTERNAL",
|
||||
direction: "telemetry",
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
const collectionRef = (await client.getWorkspace(actor, projectRef))
|
||||
.collections[0].collectionRef;
|
||||
await client.execute("device-bindings:ensure", actor, {
|
||||
projectRef,
|
||||
bindingKey: "preview-binding",
|
||||
displayName: "Preview binding",
|
||||
source: { kind: "collection", ref: collectionRef },
|
||||
targetKind: "foundry.application",
|
||||
targetRef: "application:preview-map",
|
||||
capabilities: ["observe"],
|
||||
});
|
||||
await client.execute("project-grants:upsert", actor, {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:preview-viewers",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
|
||||
const projects = await client.listProjects(actor);
|
||||
assert.equal(projects.length, 1);
|
||||
assert.equal(projects[0].counts.collections, 1);
|
||||
const workspace = await client.getWorkspace(actor, projectRef);
|
||||
assert.equal(workspace.collections[0].collectionKey, "field-devices");
|
||||
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
|
||||
assert.equal(workspace.routes[0].routeKey, "preview-route");
|
||||
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
||||
assert.equal(workspace.grants.length, 2);
|
||||
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
|
||||
assert.equal(workspace.policies.commandTransport, "disabled");
|
||||
assert.deepEqual(workspace.devices, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,24 @@ const mutationRoutes = new Map([
|
||||
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
||||
["/api/device-manager/projects:ensure", "projects:ensure"],
|
||||
["/api/device-manager/collections:ensure", "collections:ensure"],
|
||||
["/api/device-manager/project-grants:upsert", "project-grants:upsert"],
|
||||
["/api/device-manager/adapter-packages:ensure", "adapter-packages:ensure"],
|
||||
["/api/device-manager/adapter-versions:register", "adapter-versions:register"],
|
||||
["/api/device-manager/model-profiles:register", "model-profiles:register"],
|
||||
["/api/device-manager/edges:ensure", "edges:ensure"],
|
||||
["/api/device-manager/routes:ensure", "routes:ensure"],
|
||||
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
|
||||
["/api/device-manager/devices:claim", "devices:claim"],
|
||||
["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"],
|
||||
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
|
||||
[
|
||||
"/api/device-manager/device-configuration-revisions:create",
|
||||
"device-configuration-revisions:create",
|
||||
],
|
||||
[
|
||||
"/api/device-manager/device-configurations:set-desired",
|
||||
"device-configurations:set-desired",
|
||||
],
|
||||
]);
|
||||
|
||||
export function createDeviceManagerServer({
|
||||
|
||||
@@ -52,11 +52,36 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a
|
||||
name: "Pilot devices",
|
||||
description: null,
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/adapter-packages:ensure`, {
|
||||
packageKey: "generic-sensor",
|
||||
displayName: "Generic sensor",
|
||||
publisherRef: "publisher:nodedc",
|
||||
lifecycleState: "active",
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/edges:ensure`, {
|
||||
edgeKey: "preview-edge",
|
||||
displayName: "Preview Edge",
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:preview-viewers",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
});
|
||||
const workspace = await getJson(
|
||||
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||
);
|
||||
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
||||
assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices");
|
||||
assert.equal(workspace.workspace.adapterPackages[0].packageKey, "generic-sensor");
|
||||
assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge");
|
||||
assert.equal(workspace.workspace.grants.length, 2);
|
||||
assert.equal(workspace.workspace.policies.commandTransport, "disabled");
|
||||
assert.deepEqual(workspace.workspace.devices, []);
|
||||
|
||||
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
Select,
|
||||
SettingsCard,
|
||||
StatusBadge,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
createConfigurationRevision,
|
||||
ensureAdapterPackage,
|
||||
ensureDeviceBinding,
|
||||
ensureEdge,
|
||||
ensureRoute,
|
||||
registerAdapterVersion,
|
||||
registerModelProfile,
|
||||
revokeDeviceBinding,
|
||||
setDesiredConfiguration,
|
||||
upsertProjectGrant,
|
||||
} from "./api";
|
||||
import type {
|
||||
AdapterPackageView,
|
||||
AdapterVersionView,
|
||||
BindingView,
|
||||
DeviceManagerSession,
|
||||
EdgeView,
|
||||
ModelProfileView,
|
||||
ProjectWorkspace,
|
||||
} from "./types";
|
||||
|
||||
export type ControlViewId =
|
||||
| "catalog"
|
||||
| "infrastructure"
|
||||
| "sessions"
|
||||
| "bindings"
|
||||
| "commands"
|
||||
| "audit"
|
||||
| "access"
|
||||
| "settings";
|
||||
|
||||
type DialogId =
|
||||
| "adapter-package"
|
||||
| "adapter-version"
|
||||
| "model-profile"
|
||||
| "edge"
|
||||
| "route"
|
||||
| "binding"
|
||||
| "grant"
|
||||
| "configuration"
|
||||
| null;
|
||||
|
||||
export function DeviceControlView({
|
||||
view,
|
||||
workspace,
|
||||
session,
|
||||
onRefresh,
|
||||
onError,
|
||||
}: {
|
||||
view: ControlViewId;
|
||||
workspace: ProjectWorkspace;
|
||||
session: DeviceManagerSession;
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}) {
|
||||
const [dialog, setDialog] = useState<DialogId>(null);
|
||||
const capabilities = new Set(workspace.project.access.capabilities);
|
||||
const platformOwner = session.actor.hubRole === "owner";
|
||||
const close = () => setDialog(null);
|
||||
const completed = async () => {
|
||||
close();
|
||||
await onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{view === "catalog" ? (
|
||||
<CatalogView
|
||||
workspace={workspace}
|
||||
canManage={platformOwner}
|
||||
onCreatePackage={() => setDialog("adapter-package")}
|
||||
onCreateVersion={() => setDialog("adapter-version")}
|
||||
onCreateProfile={() => setDialog("model-profile")}
|
||||
/>
|
||||
) : null}
|
||||
{view === "infrastructure" ? (
|
||||
<InfrastructureView
|
||||
workspace={workspace}
|
||||
canManageCatalog={platformOwner}
|
||||
canManageRoutes={capabilities.has("route.manage")}
|
||||
onCreateEdge={() => setDialog("edge")}
|
||||
onCreateRoute={() => setDialog("route")}
|
||||
/>
|
||||
) : null}
|
||||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
||||
{view === "bindings" ? (
|
||||
<BindingsView
|
||||
workspace={workspace}
|
||||
canManage={capabilities.has("binding.manage")}
|
||||
onCreate={() => setDialog("binding")}
|
||||
onRevoke={(binding) => revokeDeviceBinding({
|
||||
projectRef: workspace.project.projectRef,
|
||||
bindingRef: binding.bindingRef,
|
||||
resolutionCode: "operator.revoked",
|
||||
}).then(onRefresh).catch(onError)}
|
||||
/>
|
||||
) : null}
|
||||
{view === "commands" ? <CommandsView workspace={workspace} /> : null}
|
||||
{view === "audit" ? <AuditView workspace={workspace} /> : null}
|
||||
{view === "access" ? (
|
||||
<AccessView
|
||||
workspace={workspace}
|
||||
canManage={capabilities.has("access.manage")}
|
||||
onCreate={() => setDialog("grant")}
|
||||
/>
|
||||
) : null}
|
||||
{view === "settings" ? (
|
||||
<SettingsView
|
||||
workspace={workspace}
|
||||
canConfigure={capabilities.has("configuration.manage")}
|
||||
onCreateConfiguration={() => setDialog("configuration")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AdapterPackageDialog
|
||||
open={dialog === "adapter-package"}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<AdapterVersionDialog
|
||||
open={dialog === "adapter-version"}
|
||||
packages={workspace.adapterPackages}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<ModelProfileDialog
|
||||
open={dialog === "model-profile"}
|
||||
versions={workspace.adapterVersions}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<EdgeDialog
|
||||
open={dialog === "edge"}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<RouteDialog
|
||||
open={dialog === "route"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<BindingDialog
|
||||
open={dialog === "binding"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<GrantDialog
|
||||
open={dialog === "grant"}
|
||||
projectRef={workspace.project.projectRef}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
<ConfigurationDialog
|
||||
open={dialog === "configuration"}
|
||||
workspace={workspace}
|
||||
onClose={close}
|
||||
onCreated={completed}
|
||||
onError={onError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreatePackage: () => void;
|
||||
onCreateVersion: () => void;
|
||||
onCreateProfile: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Adapter packages и model profiles — глобальный versioned каталог. B2 здесь не является отдельным продуктом."
|
||||
actions={canManage ? <>
|
||||
<Button size="compact" onClick={onCreatePackage}>Пакет</Button>
|
||||
<Button size="compact" onClick={onCreateVersion} disabled={!workspace.adapterPackages.length}>Версия</Button>
|
||||
<Button size="compact" variant="primary" onClick={onCreateProfile} disabled={!workspace.adapterVersions.length}>Профиль</Button>
|
||||
</> : null}
|
||||
/>
|
||||
<ControlSection title="Model profiles" count={workspace.modelProfiles.length}>
|
||||
<ResourceGrid empty="В доступном каталоге пока нет model profiles.">
|
||||
{workspace.modelProfiles.map((profile) => (
|
||||
<ResourceCard
|
||||
key={profile.modelProfileRef}
|
||||
eyebrow={`${profile.vendor} · ${profile.deviceType}`}
|
||||
title={`${profile.model}`}
|
||||
description={`${profile.protocol} · ${profile.modelProfileRef}`}
|
||||
status={profile.lifecycleState}
|
||||
meta={profile.capabilities}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Adapter packages" count={workspace.adapterPackages.length}>
|
||||
<ResourceGrid empty="Adapter packages не зарегистрированы.">
|
||||
{workspace.adapterPackages.map((adapterPackage) => (
|
||||
<ResourceCard
|
||||
key={adapterPackage.adapterPackageRef}
|
||||
eyebrow={adapterPackage.publisherRef}
|
||||
title={adapterPackage.displayName}
|
||||
description={adapterPackage.packageKey}
|
||||
status={adapterPackage.lifecycleState}
|
||||
meta={workspace.adapterVersions
|
||||
.filter((version) => version.adapterPackageRef === adapterPackage.adapterPackageRef)
|
||||
.map((version) => `${version.version} · ${version.lifecycleState}`)}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManageCatalog: boolean;
|
||||
canManageRoutes: boolean;
|
||||
onCreateEdge: () => void;
|
||||
onCreateRoute: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Edge — зарегистрированная внешняя роль. Route связывает проект, Edge, profile и логический listener без credentials."
|
||||
actions={<>
|
||||
{canManageCatalog ? <Button size="compact" onClick={onCreateEdge}>Новый Edge</Button> : null}
|
||||
{canManageRoutes ? <Button size="compact" variant="primary" onClick={onCreateRoute} disabled={!workspace.edges.length || !workspace.modelProfiles.length}>Новый маршрут</Button> : null}
|
||||
</>}
|
||||
/>
|
||||
<ControlSection title="Routes" count={workspace.routes.length}>
|
||||
<ResourceGrid empty="Маршрутов в проекте пока нет.">
|
||||
{workspace.routes.map((route) => (
|
||||
<ResourceCard
|
||||
key={route.routeRef}
|
||||
eyebrow={`${route.protocol} · ${route.direction}`}
|
||||
title={route.displayName}
|
||||
description={`${route.edgeName} → ${route.profileName}`}
|
||||
status={route.lifecycleState}
|
||||
meta={[
|
||||
route.listenerRef,
|
||||
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
<ControlSection title="Edges" count={workspace.edges.length}>
|
||||
<ResourceGrid empty="Доступных Edge registrations нет.">
|
||||
{workspace.edges.map((edge) => (
|
||||
<ResourceCard
|
||||
key={edge.edgeRef}
|
||||
eyebrow="DEVICE GATEWAY EDGE"
|
||||
title={edge.displayName}
|
||||
description={edge.edgeKey}
|
||||
status={edge.lifecycleState}
|
||||
meta={edge.deploymentRef ? [edge.deploymentRef] : []}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
</ControlSection>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar copy="Сессии принадлежат Gateway runtime. Device Manager только читает bounded presence/counter projection." />
|
||||
<ResourceList empty="Gateway sessions пока не наблюдались.">
|
||||
{workspace.sessions.map((session) => (
|
||||
<ResourceRow
|
||||
key={session.sessionRef}
|
||||
title={session.deviceName || "Неидентифицированная сессия"}
|
||||
description={`${session.routeName} · ${session.protocol} · ${formatDate(session.lastSeenAt)}`}
|
||||
status={session.lifecycleState}
|
||||
trailing={`${session.frameCount} frames · ${formatBytes(session.byteCount)}`}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreate: () => void;
|
||||
onRevoke: (binding: BindingView) => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Binding создаёт только source approval. Active появится лишь после отдельного external proof от целевой системы."
|
||||
actions={canManage ? <Button variant="primary" onClick={onCreate} disabled={!workspace.collections.length && !workspace.devices.length}>Новый binding</Button> : null}
|
||||
/>
|
||||
<ResourceList empty="Data bindings пока не создавались.">
|
||||
{workspace.bindings.map((binding) => (
|
||||
<ResourceRow
|
||||
key={binding.bindingRef}
|
||||
title={binding.displayName}
|
||||
description={`${binding.source.displayName} → ${binding.target.kind}:${binding.target.ref}`}
|
||||
status={binding.lifecycleState}
|
||||
trailing={binding.lifecycleState !== "revoked" && canManage ? (
|
||||
<Button size="compact" variant="danger" onClick={() => onRevoke(binding)}>Отозвать</Button>
|
||||
) : binding.capabilities.join(", ")}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandsView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<div className="device-control-command-policy">
|
||||
<Icon name="lock" />
|
||||
<div>
|
||||
<strong>Command transport выключен</strong>
|
||||
<p>Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния.</p>
|
||||
</div>
|
||||
<StatusBadge tone="warning">{workspace.policies.commandTransport}</StatusBadge>
|
||||
</div>
|
||||
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
|
||||
{workspace.commands.map((command) => (
|
||||
<ResourceRow
|
||||
key={command.commandRef}
|
||||
title={`${command.commandType} · ${command.deviceName}`}
|
||||
description={`${command.riskClass} · expires ${formatDate(command.expiresAt)}`}
|
||||
status={command.lifecycleState}
|
||||
trailing={command.terminalReasonCode || command.commandKey}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditView({ workspace }: { workspace: ProjectWorkspace }) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar copy="Показывается immutable metadata projection. Audit payload намеренно не выдаётся в браузер." />
|
||||
<ResourceList empty="Audit events для проекта отсутствуют.">
|
||||
{workspace.auditEvents.map((event) => (
|
||||
<ResourceRow
|
||||
key={event.auditEventRef}
|
||||
title={event.eventType}
|
||||
description={`${event.actorRef} · ${formatDate(event.occurredAt)}`}
|
||||
status="recorded"
|
||||
trailing={event.deviceRef || event.discoveryRef || "project"}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessView({ workspace, canManage, onCreate }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canManage: boolean;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<ControlToolbar
|
||||
copy="Hub задаёт потолок, а Device Project grant — конкретную роль. Direct user grant имеет приоритет над group grants."
|
||||
actions={canManage ? <Button variant="primary" onClick={onCreate}>Добавить доступ</Button> : null}
|
||||
/>
|
||||
<ResourceList empty="Project grants недоступны или ещё не созданы.">
|
||||
{workspace.grants.map((grant) => (
|
||||
<ResourceRow
|
||||
key={grant.grantRef}
|
||||
title={grant.principalRef}
|
||||
description={`${grant.principalKind} · ${grant.projectRole}`}
|
||||
status={grant.lifecycleState}
|
||||
trailing={grant.capabilityDeny.length ? `deny: ${grant.capabilityDeny.join(", ")}` : "role capabilities"}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ workspace, canConfigure, onCreateConfiguration }: {
|
||||
workspace: ProjectWorkspace;
|
||||
canConfigure: boolean;
|
||||
onCreateConfiguration: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ControlStack>
|
||||
<div className="device-control-policy-grid">
|
||||
<PolicyCard label="Identifiers" value={workspace.policies.identifierProjection} />
|
||||
<PolicyCard label="Audit payload" value={workspace.policies.auditPayloadProjection} />
|
||||
<PolicyCard label="Command API" value={workspace.policies.commandPlanningApi} />
|
||||
</div>
|
||||
<ControlToolbar
|
||||
copy="Configuration revisions immutable. Desired и applied — разные указатели; создание desired не означает применение устройством."
|
||||
actions={canConfigure ? <Button variant="primary" onClick={onCreateConfiguration} disabled={!workspace.devices.length}>Новая desired revision</Button> : null}
|
||||
/>
|
||||
<ResourceList empty="Configuration state пока отсутствует.">
|
||||
{workspace.configurationStates.map((state) => (
|
||||
<ResourceRow
|
||||
key={state.deviceRef}
|
||||
title={state.deviceName}
|
||||
description={`desired: ${shortRef(state.desiredConfigurationRevisionRef)} · applied: ${shortRef(state.appliedConfigurationRevisionRef)}`}
|
||||
status={state.appliedConfigurationRevisionRef === state.desiredConfigurationRevisionRef ? "applied" : "pending"}
|
||||
trailing={formatDate(state.updatedAt)}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
<ControlSection title="Immutable revisions" count={workspace.configurationRevisions.length}>
|
||||
<ResourceList empty="Configuration revisions отсутствуют.">
|
||||
{workspace.configurationRevisions.map((revision) => (
|
||||
<ResourceRow
|
||||
key={revision.configurationRevisionRef}
|
||||
title={`${revision.deviceName} · revision ${revision.revisionNumber}`}
|
||||
description={revision.changeSummary || revision.modelProfileRef}
|
||||
status="immutable"
|
||||
trailing={shortDigest(revision.configurationDigest)}
|
||||
/>
|
||||
))}
|
||||
</ResourceList>
|
||||
</ControlSection>
|
||||
</ControlStack>
|
||||
);
|
||||
}
|
||||
|
||||
function AdapterPackageDialog(props: DialogBaseProps) {
|
||||
const [packageKey, setPackageKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [publisherRef, setPublisherRef] = useState("");
|
||||
return <FormWindow {...props} id="adapter-package-form" title="Adapter package" submit={async () => {
|
||||
await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" });
|
||||
}}>
|
||||
<KeyField label="Ключ пакета" value={packageKey} onChange={setPackageKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<TextField label="Publisher ref" value={publisherRef} onChange={(event) => setPublisherRef(event.target.value)} required />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function AdapterVersionDialog({ packages, ...props }: DialogBaseProps & { packages: AdapterPackageView[] }) {
|
||||
const [packageRef, setPackageRef] = useState(packages[0]?.adapterPackageRef ?? "");
|
||||
const [version, setVersion] = useState("");
|
||||
const [runtimeRef, setRuntimeRef] = useState("");
|
||||
const [digest, setDigest] = useState("");
|
||||
const [contractVersion, setContractVersion] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
useEffect(() => {
|
||||
if (!packages.some((item) => item.adapterPackageRef === packageRef)) {
|
||||
setPackageRef(packages[0]?.adapterPackageRef ?? "");
|
||||
}
|
||||
}, [packageRef, packages]);
|
||||
return <FormWindow {...props} id="adapter-version-form" title="Версия адаптера" disabled={!packageRef} submit={async () => {
|
||||
await registerAdapterVersion({
|
||||
adapterPackageRef: packageRef,
|
||||
version,
|
||||
runtimePackageRef: runtimeRef,
|
||||
contentDigest: digest,
|
||||
contractVersion,
|
||||
capabilities: commaList(capabilities),
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
}}>
|
||||
<Select label="Adapter package" value={packageRef} onChange={setPackageRef} options={packages.map((item) => ({ value: item.adapterPackageRef, label: item.displayName }))} />
|
||||
<TextField label="SemVer" value={version} onChange={(event) => setVersion(event.target.value)} required placeholder="1.0.0" />
|
||||
<TextField label="Runtime artifact ref" value={runtimeRef} onChange={(event) => setRuntimeRef(event.target.value)} required />
|
||||
<TextField label="Content digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
||||
<TextField label="Contract version" value={contractVersion} onChange={(event) => setContractVersion(event.target.value)} required />
|
||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="Через запятую" />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions: AdapterVersionView[] }) {
|
||||
const [versionRef, setVersionRef] = useState(versions[0]?.adapterVersionRef ?? "");
|
||||
const [profileRef, setProfileRef] = useState("");
|
||||
const [schemaVersion, setSchemaVersion] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [deviceType, setDeviceType] = useState("");
|
||||
const [protocol, setProtocol] = useState("");
|
||||
const [schemaRef, setSchemaRef] = useState("");
|
||||
const [digest, setDigest] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("");
|
||||
useEffect(() => {
|
||||
if (!versions.some((item) => item.adapterVersionRef === versionRef)) {
|
||||
setVersionRef(versions[0]?.adapterVersionRef ?? "");
|
||||
}
|
||||
}, [versionRef, versions]);
|
||||
return <FormWindow {...props} id="model-profile-form" title="Model profile" disabled={!versionRef} submit={async () => {
|
||||
await registerModelProfile({
|
||||
adapterVersionRef: versionRef,
|
||||
profileRef,
|
||||
schemaVersion,
|
||||
vendor,
|
||||
model,
|
||||
deviceType,
|
||||
protocol: protocol.toUpperCase(),
|
||||
schemaArtifactRef: schemaRef,
|
||||
profileDigest: digest,
|
||||
capabilities: commaList(capabilities),
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
}}>
|
||||
<Select label="Adapter version" value={versionRef} onChange={setVersionRef} options={versions.map((item) => ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} />
|
||||
<TextField label="Profile ref" value={profileRef} onChange={(event) => setProfileRef(event.target.value)} required />
|
||||
<TextField label="Schema version" value={schemaVersion} onChange={(event) => setSchemaVersion(event.target.value)} required />
|
||||
<TextField label="Vendor" value={vendor} onChange={(event) => setVendor(event.target.value)} required />
|
||||
<TextField label="Model" value={model} onChange={(event) => setModel(event.target.value)} required />
|
||||
<KeyField label="Device type" value={deviceType} onChange={setDeviceType} />
|
||||
<TextField label="Protocol" value={protocol} onChange={(event) => setProtocol(event.target.value)} required />
|
||||
<TextField label="Schema artifact ref" value={schemaRef} onChange={(event) => setSchemaRef(event.target.value)} required />
|
||||
<TextField label="Profile digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function EdgeDialog(props: DialogBaseProps) {
|
||||
const [edgeKey, setEdgeKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [deploymentRef, setDeploymentRef] = useState("");
|
||||
return <FormWindow {...props} id="edge-form" title="Новый Edge" submit={async () => {
|
||||
await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" });
|
||||
}}>
|
||||
<KeyField label="Edge key" value={edgeKey} onChange={setEdgeKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<TextField label="Deployment ref" value={deploymentRef} onChange={(event) => setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function RouteDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||||
const [routeKey, setRouteKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [edgeRef, setEdgeRef] = useState(workspace.edges[0]?.edgeRef ?? "");
|
||||
const [profileRef, setProfileRef] = useState(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
||||
const [listenerRef, setListenerRef] = useState("");
|
||||
const profile = workspace.modelProfiles.find((item) => item.modelProfileRef === profileRef);
|
||||
useEffect(() => {
|
||||
if (!workspace.edges.some((item) => item.edgeRef === edgeRef)) {
|
||||
setEdgeRef(workspace.edges[0]?.edgeRef ?? "");
|
||||
}
|
||||
if (!workspace.modelProfiles.some((item) => item.modelProfileRef === profileRef)) {
|
||||
setProfileRef(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
||||
}
|
||||
}, [edgeRef, profileRef, workspace.edges, workspace.modelProfiles]);
|
||||
return <FormWindow {...props} id="route-form" title="Новый маршрут" disabled={!edgeRef || !profileRef} submit={async () => {
|
||||
await ensureRoute({
|
||||
projectRef: workspace.project.projectRef,
|
||||
routeKey,
|
||||
displayName,
|
||||
edgeRef,
|
||||
modelProfileRef: profileRef,
|
||||
listenerRef,
|
||||
protocol: profile?.protocol || "INTERNAL",
|
||||
direction: "telemetry",
|
||||
lifecycleState: "draft",
|
||||
});
|
||||
}}>
|
||||
<KeyField label="Route key" value={routeKey} onChange={setRouteKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<Select label="Edge" value={edgeRef} onChange={setEdgeRef} options={workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} />
|
||||
<Select label="Model profile" value={profileRef} onChange={setProfileRef} options={workspace.modelProfiles.map((item) => ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} />
|
||||
<TextField label="Listener ref" value={listenerRef} onChange={(event) => setListenerRef(event.target.value)} required />
|
||||
<p className="device-manager-card-copy">Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.</p>
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function BindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||||
const sources = useMemo(() => [
|
||||
...workspace.collections.map((item) => ({ value: `collection|${item.collectionRef}`, label: item.name })),
|
||||
...workspace.devices.map((item) => ({ value: `device|${item.deviceRef}`, label: item.displayName })),
|
||||
], [workspace]);
|
||||
const [sourceValue, setSourceValue] = useState(sources[0]?.value ?? "");
|
||||
const [bindingKey, setBindingKey] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [targetKind, setTargetKind] = useState("");
|
||||
const [targetRef, setTargetRef] = useState("");
|
||||
const [capabilities, setCapabilities] = useState("observe");
|
||||
useEffect(() => {
|
||||
if (!sources.some((item) => item.value === sourceValue)) {
|
||||
setSourceValue(sources[0]?.value ?? "");
|
||||
}
|
||||
}, [sourceValue, sources]);
|
||||
return <FormWindow {...props} id="binding-form" title="Новый data binding" disabled={!sourceValue} submit={async () => {
|
||||
const [kind, ref] = sourceValue.split("|");
|
||||
await ensureDeviceBinding({
|
||||
projectRef: workspace.project.projectRef,
|
||||
bindingKey,
|
||||
displayName,
|
||||
source: { kind: kind as "device" | "collection", ref },
|
||||
targetKind,
|
||||
targetRef,
|
||||
capabilities: commaList(capabilities),
|
||||
});
|
||||
}}>
|
||||
<Select label="Source" value={sourceValue} onChange={setSourceValue} options={sources} />
|
||||
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
|
||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
||||
<TextField label="Target kind" value={targetKind} onChange={(event) => setTargetKind(event.target.value)} required placeholder="foundry.application" />
|
||||
<TextField label="Target ref" value={targetRef} onChange={(event) => setTargetRef(event.target.value)} required />
|
||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="observe, inspect, configure, command" required />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function GrantDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
|
||||
const [principalKind, setPrincipalKind] = useState<"user" | "group">("user");
|
||||
const [principalRef, setPrincipalRef] = useState("");
|
||||
const [role, setRole] = useState("viewer");
|
||||
const [allow, setAllow] = useState("");
|
||||
const [deny, setDeny] = useState("");
|
||||
return <FormWindow {...props} id="grant-form" title="Project access" submit={async () => {
|
||||
await upsertProjectGrant({
|
||||
projectRef,
|
||||
principalKind,
|
||||
principalRef,
|
||||
projectRole: role,
|
||||
capabilityAllow: commaList(allow),
|
||||
capabilityDeny: commaList(deny),
|
||||
lifecycleState: "active",
|
||||
});
|
||||
}}>
|
||||
<Select label="Principal type" value={principalKind} onChange={setPrincipalKind} options={[{ value: "user", label: "User" }, { value: "group", label: "Group" }]} />
|
||||
<TextField label="Principal ref" value={principalRef} onChange={(event) => setPrincipalRef(event.target.value)} required />
|
||||
<Select label="Project role" value={role} onChange={setRole} options={["viewer", "operator", "engineer", "admin", "owner"].map((value) => ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} />
|
||||
<TextField label="Capability allow" value={allow} onChange={(event) => setAllow(event.target.value)} description="Опциональные точечные добавления" />
|
||||
<TextField label="Capability deny" value={deny} onChange={(event) => setDeny(event.target.value)} description="Deny имеет приоритет" />
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
function ConfigurationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
||||
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
|
||||
const [configuration, setConfiguration] = useState("{\n \"reporting_interval_seconds\": 30\n}");
|
||||
const [summary, setSummary] = useState("");
|
||||
useEffect(() => {
|
||||
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
|
||||
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
|
||||
}
|
||||
}, [deviceRef, workspace.devices]);
|
||||
return <FormWindow {...props} id="configuration-form" title="Новая desired configuration" disabled={!deviceRef} submit={async () => {
|
||||
const parsed = JSON.parse(configuration) as Record<string, unknown>;
|
||||
const created = await createConfigurationRevision({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef,
|
||||
configuration: parsed,
|
||||
changeSummary: summary || null,
|
||||
});
|
||||
await setDesiredConfiguration({
|
||||
projectRef: workspace.project.projectRef,
|
||||
deviceRef,
|
||||
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
|
||||
});
|
||||
}}>
|
||||
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
|
||||
<TextAreaField label="Configuration JSON" value={configuration} onChange={(event) => setConfiguration(event.target.value)} required />
|
||||
<TextAreaField label="Change summary" value={summary} onChange={(event) => setSummary(event.target.value)} />
|
||||
<p className="device-manager-card-copy">Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.</p>
|
||||
</FormWindow>;
|
||||
}
|
||||
|
||||
interface DialogBaseProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & {
|
||||
id: string;
|
||||
title: string;
|
||||
submit: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setPending(true);
|
||||
try {
|
||||
await submit();
|
||||
await onCreated();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Window open={open} title={title} onClose={onClose} footer={
|
||||
<WindowFooterActions>
|
||||
<Button variant="ghost" onClick={onClose}>Отмена</Button>
|
||||
<Button type="submit" form={id} variant="primary" disabled={disabled || pending}>{pending ? "Сохраняем…" : "Сохранить"}</Button>
|
||||
</WindowFooterActions>
|
||||
}>
|
||||
<form id={id} className="device-manager-form device-control-form" onSubmit={handleSubmit}>{children}</form>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return <TextField label={label} value={value} onChange={(event) => onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />;
|
||||
}
|
||||
|
||||
function ControlStack({ children }: { children: ReactNode }) {
|
||||
return <div className="device-manager-stack device-control-stack">{children}</div>;
|
||||
}
|
||||
|
||||
function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) {
|
||||
return <div className="device-manager-panel-toolbar device-control-toolbar"><p>{copy}</p>{actions ? <div className="device-control-toolbar__actions">{actions}</div> : null}</div>;
|
||||
}
|
||||
|
||||
function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
||||
return <section className="device-control-section"><div className="device-control-section__title"><h3>{title}</h3><StatusBadge>{count}</StatusBadge></div>{children}</section>;
|
||||
}
|
||||
|
||||
function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) {
|
||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||||
return hasChildren ? <div className="device-control-resource-grid">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
||||
}
|
||||
|
||||
function ResourceCard({ eyebrow, title, description, status, meta }: { eyebrow: string; title: string; description: string; status: string; meta: string[] }) {
|
||||
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<StatusBadge tone={statusTone(status)}>{status}</StatusBadge>}>
|
||||
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <span key={item}>{item}</span>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
|
||||
function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
|
||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||||
return hasChildren ? <div className="device-manager-entity-list">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
||||
}
|
||||
|
||||
function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
|
||||
return <GlassSurface className="device-manager-entity device-control-row" padding="md" tone="soft">
|
||||
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
|
||||
<span className="device-manager-entity__body"><strong>{title}</strong><small>{description}</small></span>
|
||||
<span className="device-control-row__status"><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{typeof trailing === "string" ? <small>{trailing}</small> : trailing}</span>
|
||||
</GlassSurface>;
|
||||
}
|
||||
|
||||
function PolicyCard({ label, value }: { label: string; value: string }) {
|
||||
return <GlassSurface padding="md" tone="soft"><small>{label}</small><strong>{value}</strong></GlassSurface>;
|
||||
}
|
||||
|
||||
function commaList(value: string) {
|
||||
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
||||
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
||||
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
||||
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function shortRef(value: string | null) {
|
||||
return value ? `${value.slice(0, 18)}…` : "—";
|
||||
}
|
||||
|
||||
function shortDigest(value: string) {
|
||||
return `${value.slice(0, 15)}…${value.slice(-8)}`;
|
||||
}
|
||||
@@ -36,14 +36,31 @@ import type {
|
||||
ProjectSummary,
|
||||
ProjectWorkspace,
|
||||
} from "./types";
|
||||
import {
|
||||
DeviceControlView,
|
||||
type ControlViewId,
|
||||
} from "./DeviceControlViews";
|
||||
|
||||
type ViewId = "overview" | "inventory" | "discovery" | "collections";
|
||||
type ViewId =
|
||||
| "overview"
|
||||
| "inventory"
|
||||
| "discovery"
|
||||
| "collections"
|
||||
| ControlViewId;
|
||||
|
||||
const navigationItems = [
|
||||
{ id: "overview", label: "Обзор", icon: "grid" },
|
||||
{ id: "inventory", label: "Устройства", icon: "apps" },
|
||||
{ id: "discovery", label: "Подключение", icon: "network" },
|
||||
{ id: "collections", label: "Коллекции", icon: "folder" },
|
||||
{ id: "overview", label: "Обзор", icon: "grid", capability: null },
|
||||
{ id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" },
|
||||
{ id: "discovery", label: "Подключение", icon: "network", capability: "inventory.read" },
|
||||
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
|
||||
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
|
||||
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
|
||||
{ id: "sessions", label: "Сессии", icon: "activity", capability: "telemetry.observe" },
|
||||
{ id: "bindings", label: "Bindings", icon: "external", capability: "binding.manage" },
|
||||
{ id: "commands", label: "Команды", icon: "target", capability: "command.plan" },
|
||||
{ id: "audit", label: "Аудит", icon: "clipboard", capability: "audit.read" },
|
||||
{ id: "access", label: "Доступ", icon: "users", capability: "access.manage" },
|
||||
{ id: "settings", label: "Настройки", icon: "settings", capability: "configuration.read" },
|
||||
] as const;
|
||||
|
||||
export function DeviceManagerApp() {
|
||||
@@ -115,6 +132,9 @@ export function DeviceManagerApp() {
|
||||
);
|
||||
const canManageCollections = capabilities.has("collection.manage");
|
||||
const canClaim = capabilities.has("device.claim");
|
||||
const visibleNavigationItems = navigationItems.filter(
|
||||
(item) => item.capability === null || capabilities.has(item.capability),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
|
||||
@@ -227,7 +247,7 @@ export function DeviceManagerApp() {
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
items={activeProject ? navigationItems.map((item) => ({
|
||||
items={activeProject ? visibleNavigationItems.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
icon: <Icon name={item.icon} />,
|
||||
@@ -260,6 +280,9 @@ export function DeviceManagerApp() {
|
||||
workspace={workspace}
|
||||
canManageCollections={canManageCollections}
|
||||
canClaim={canClaim}
|
||||
session={session}
|
||||
onRefresh={refreshWorkspace}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
onCreateCollection={() => setCollectionDialogOpen(true)}
|
||||
onClaim={setClaimEnrollment}
|
||||
/>
|
||||
@@ -420,15 +443,27 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectView({ view, workspace, canManageCollections, canClaim, onCreateCollection, onClaim }: {
|
||||
function ProjectView({ view, workspace, canManageCollections, canClaim, session, onRefresh, onError, onCreateCollection, onClaim }: {
|
||||
view: ViewId;
|
||||
workspace: ProjectWorkspace | null;
|
||||
canManageCollections: boolean;
|
||||
canClaim: boolean;
|
||||
session: DeviceManagerSession;
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
onCreateCollection: () => void;
|
||||
onClaim: (enrollment: EnrollmentView) => void;
|
||||
}) {
|
||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
||||
if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
|
||||
return <DeviceControlView
|
||||
view={view as ControlViewId}
|
||||
workspace={workspace}
|
||||
session={session}
|
||||
onRefresh={onRefresh}
|
||||
onError={onError}
|
||||
/>;
|
||||
}
|
||||
if (view === "inventory") return (
|
||||
<EntityList
|
||||
empty="В проекте ещё нет зарегистрированных устройств."
|
||||
@@ -616,7 +651,20 @@ function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[])
|
||||
}
|
||||
|
||||
function viewTitle(view: ViewId) {
|
||||
return ({ overview: "Обзор проекта", inventory: "Устройства", discovery: "Подключение", collections: "Коллекции" })[view];
|
||||
return ({
|
||||
overview: "Обзор проекта",
|
||||
inventory: "Устройства",
|
||||
discovery: "Подключение",
|
||||
collections: "Коллекции",
|
||||
catalog: "Модели и адаптеры",
|
||||
infrastructure: "Edges и маршруты",
|
||||
sessions: "Gateway sessions",
|
||||
bindings: "Data bindings",
|
||||
commands: "Command ledger",
|
||||
audit: "Immutable audit",
|
||||
access: "Доступ к проекту",
|
||||
settings: "Настройки и конфигурации",
|
||||
})[view];
|
||||
}
|
||||
|
||||
function errorText(reason: unknown) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type {
|
||||
AdapterPackageView,
|
||||
AdapterVersionView,
|
||||
BindingView,
|
||||
ConfigurationRevisionView,
|
||||
EdgeView,
|
||||
ModelProfileView,
|
||||
ProjectGrantView,
|
||||
DeviceManagerSession,
|
||||
ProjectSummary,
|
||||
ProjectWorkspace,
|
||||
RouteView,
|
||||
ScopeKind,
|
||||
} from "./types";
|
||||
|
||||
@@ -60,8 +68,144 @@ export async function claimDevice(input: {
|
||||
return mutate("/api/device-manager/devices:claim", input);
|
||||
}
|
||||
|
||||
async function mutate(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true; replayed: boolean; result: unknown }>(path, {
|
||||
export async function upsertProjectGrant(input: {
|
||||
projectRef: string;
|
||||
principalKind: "user" | "group";
|
||||
principalRef: string;
|
||||
projectRole: string;
|
||||
capabilityAllow: string[];
|
||||
capabilityDeny: string[];
|
||||
lifecycleState: "active" | "revoked";
|
||||
}) {
|
||||
return mutate<{ created: boolean; grant: ProjectGrantView }>(
|
||||
"/api/device-manager/project-grants:upsert",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureAdapterPackage(input: {
|
||||
packageKey: string;
|
||||
displayName: string;
|
||||
publisherRef: string;
|
||||
lifecycleState: "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; adapterPackage: AdapterPackageView }>(
|
||||
"/api/device-manager/adapter-packages:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerAdapterVersion(input: {
|
||||
adapterPackageRef: string;
|
||||
version: string;
|
||||
runtimePackageRef: string;
|
||||
contentDigest: string;
|
||||
contractVersion: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: "draft" | "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; adapterVersion: AdapterVersionView }>(
|
||||
"/api/device-manager/adapter-versions:register",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function registerModelProfile(input: {
|
||||
adapterVersionRef: string;
|
||||
profileRef: string;
|
||||
schemaVersion: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
deviceType: string;
|
||||
protocol: string;
|
||||
schemaArtifactRef: string;
|
||||
profileDigest: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: "draft" | "active" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; modelProfile: ModelProfileView }>(
|
||||
"/api/device-manager/model-profiles:register",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureEdge(input: {
|
||||
edgeKey: string;
|
||||
displayName: string;
|
||||
deploymentRef: string | null;
|
||||
lifecycleState: "provisioning" | "active" | "suspended" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; edge: EdgeView }>(
|
||||
"/api/device-manager/edges:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureRoute(input: {
|
||||
projectRef: string;
|
||||
routeKey: string;
|
||||
displayName: string;
|
||||
edgeRef: string;
|
||||
modelProfileRef: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: "telemetry" | "bidirectional";
|
||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
||||
}) {
|
||||
return mutate<{ created: boolean; route: RouteView }>(
|
||||
"/api/device-manager/routes:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function ensureDeviceBinding(input: {
|
||||
projectRef: string;
|
||||
bindingKey: string;
|
||||
displayName: string;
|
||||
source: { kind: "device" | "collection"; ref: string };
|
||||
targetKind: string;
|
||||
targetRef: string;
|
||||
capabilities: string[];
|
||||
}) {
|
||||
return mutate<{ created: boolean; binding: BindingView }>(
|
||||
"/api/device-manager/device-bindings:ensure",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeDeviceBinding(input: {
|
||||
projectRef: string;
|
||||
bindingRef: string;
|
||||
resolutionCode: string;
|
||||
}) {
|
||||
return mutate<{ revoked: boolean; binding: BindingView }>(
|
||||
"/api/device-manager/device-bindings:revoke",
|
||||
input,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createConfigurationRevision(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
configuration: Record<string, unknown>;
|
||||
changeSummary: string | null;
|
||||
}) {
|
||||
return mutate<{
|
||||
created: boolean;
|
||||
configurationRevision: ConfigurationRevisionView;
|
||||
}>("/api/device-manager/device-configuration-revisions:create", input);
|
||||
}
|
||||
|
||||
export async function setDesiredConfiguration(input: {
|
||||
projectRef: string;
|
||||
deviceRef: string;
|
||||
configurationRevisionRef: string;
|
||||
}) {
|
||||
return mutate("/api/device-manager/device-configurations:set-desired", input);
|
||||
}
|
||||
|
||||
async function mutate<T = unknown>(path: string, input: unknown) {
|
||||
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -3,6 +3,161 @@
|
||||
background: #0b0d0f;
|
||||
}
|
||||
|
||||
.device-control-stack {
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.device-control-toolbar {
|
||||
align-items: center;
|
||||
padding: 2px 0 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.device-control-toolbar > p {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.device-control-toolbar__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-control-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-control-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-control-section__title h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.device-control-resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-control-row {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-control-row__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.device-control-row__status small {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-control-command-policy {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(255, 193, 92, 0.22);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 193, 92, 0.06);
|
||||
}
|
||||
|
||||
.device-control-command-policy > svg {
|
||||
color: #ffc15c;
|
||||
}
|
||||
|
||||
.device-control-command-policy strong,
|
||||
.device-control-command-policy p {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.device-control-command-policy p {
|
||||
margin-top: 5px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.device-control-policy-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-control-policy-grid .nodedc-glass-surface {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-control-policy-grid small {
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
|
||||
.device-control-policy-grid strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-control-form {
|
||||
max-height: min(62vh, 680px);
|
||||
overflow-y: auto;
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.device-control-resource-grid,
|
||||
.device-control-policy-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.device-control-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.device-control-toolbar__actions,
|
||||
.device-control-toolbar__actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-control-command-policy {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.device-control-command-policy > .nodedc-status {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.device-control-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.device-control-row__status {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
|
||||
@@ -94,10 +94,184 @@ export interface EnrollmentView {
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterPackageView {
|
||||
adapterPackageRef: string;
|
||||
packageKey: string;
|
||||
displayName: string;
|
||||
publisherRef: string;
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterVersionView {
|
||||
adapterVersionRef: string;
|
||||
adapterPackageRef: string;
|
||||
version: string;
|
||||
runtimePackageRef: string;
|
||||
contentDigest: string;
|
||||
contractVersion: string;
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ModelProfileView {
|
||||
modelProfileRef: string;
|
||||
adapterVersionRef: string | null;
|
||||
schemaVersion: string;
|
||||
vendor: string;
|
||||
model: string;
|
||||
deviceType: string;
|
||||
protocol: string;
|
||||
schemaArtifactRef: string | null;
|
||||
profileDigest: string | null;
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface EdgeView {
|
||||
edgeRef: string;
|
||||
edgeKey: string;
|
||||
displayName: string;
|
||||
deploymentRef: string | null;
|
||||
lifecycleState: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RouteView {
|
||||
routeRef: string;
|
||||
routeKey: string;
|
||||
displayName: string;
|
||||
edgeRef: string;
|
||||
edgeName: string;
|
||||
modelProfileRef: string;
|
||||
profileName: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: string;
|
||||
lifecycleState: string;
|
||||
sessionCount: number;
|
||||
activeSessionCount: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface SessionView {
|
||||
sessionRef: string;
|
||||
routeRef: string;
|
||||
routeName: string;
|
||||
deviceRef: string | null;
|
||||
deviceName: string | null;
|
||||
protocol: string;
|
||||
lifecycleState: string;
|
||||
connectedAt: string | null;
|
||||
lastSeenAt: string | null;
|
||||
disconnectedAt: string | null;
|
||||
closeReasonCode: string | null;
|
||||
frameCount: number;
|
||||
byteCount: number;
|
||||
}
|
||||
|
||||
export interface BindingView {
|
||||
bindingRef: string;
|
||||
bindingKey: string;
|
||||
displayName: string;
|
||||
source: { kind: string; ref: string; displayName: string };
|
||||
target: { kind: string; ref: string };
|
||||
capabilities: string[];
|
||||
lifecycleState: string;
|
||||
sourceApprovedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigurationRevisionView {
|
||||
configurationRevisionRef: string;
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
revisionNumber: number;
|
||||
modelProfileRef: string;
|
||||
schemaArtifactRef: string;
|
||||
configurationDigest: string;
|
||||
changeSummary: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
export interface ConfigurationStateView {
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
desiredConfigurationRevisionRef: string | null;
|
||||
appliedConfigurationRevisionRef: string | null;
|
||||
appliedAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CommandView {
|
||||
commandRef: string;
|
||||
deviceRef: string;
|
||||
deviceName: string;
|
||||
commandKey: string;
|
||||
commandCatalogRef: string;
|
||||
commandType: string;
|
||||
riskClass: string;
|
||||
lifecycleState: string;
|
||||
plannedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
confirmedAt: string | null;
|
||||
dispatchedAt: string | null;
|
||||
acknowledgedAt: string | null;
|
||||
terminalAt: string | null;
|
||||
terminalReasonCode: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEventView {
|
||||
auditEventRef: string;
|
||||
eventType: string;
|
||||
actorRef: string;
|
||||
deviceRef: string | null;
|
||||
discoveryRef: string | null;
|
||||
occurredAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectGrantView {
|
||||
grantRef: string;
|
||||
principalKind: "user" | "group";
|
||||
principalRef: string;
|
||||
projectRole: string;
|
||||
capabilityAllow: string[];
|
||||
capabilityDeny: string[];
|
||||
lifecycleState: string;
|
||||
}
|
||||
|
||||
export interface ProjectWorkspace {
|
||||
project: ProjectSummary;
|
||||
devices: DeviceView[];
|
||||
collections: CollectionView[];
|
||||
discoveries: DiscoveryView[];
|
||||
enrollments: EnrollmentView[];
|
||||
adapterPackages: AdapterPackageView[];
|
||||
adapterVersions: AdapterVersionView[];
|
||||
modelProfiles: ModelProfileView[];
|
||||
edges: EdgeView[];
|
||||
routes: RouteView[];
|
||||
sessions: SessionView[];
|
||||
bindings: BindingView[];
|
||||
configurationRevisions: ConfigurationRevisionView[];
|
||||
configurationStates: ConfigurationStateView[];
|
||||
commands: CommandView[];
|
||||
auditEvents: AuditEventView[];
|
||||
grants: ProjectGrantView[];
|
||||
policies: {
|
||||
commandTransport: "disabled" | "enabled";
|
||||
commandPlanningApi: "disabled" | "enabled";
|
||||
identifierProjection: string;
|
||||
auditPayloadProjection: string;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user