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
|
- 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.
|
never embedded into client assets or accepted as a raw environment value.
|
||||||
- Device Control Core owns authorization, lifecycle validation, idempotency and persistence.
|
- Device Control Core owns authorization, lifecycle validation, idempotency and persistence.
|
||||||
- Query responses contain masked identifiers only. Digests and credential references stay
|
- Query responses contain masked identifiers and bounded metadata only. Identifier and
|
||||||
inside Device Control Core.
|
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
|
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
|
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"],
|
["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"],
|
||||||
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
||||||
["collections:ensure", "/internal/v1/management/collections: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"],
|
["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 } = {}) {
|
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
||||||
@@ -67,6 +84,36 @@ export function createLocalPreviewDeviceCore() {
|
|||||||
const ownerScopes = new Map();
|
const ownerScopes = new Map();
|
||||||
const projects = new Map();
|
const projects = new Map();
|
||||||
const collections = 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) {
|
function projectSummary(project) {
|
||||||
const projectCollections = [...collections.values()]
|
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 {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
async listProjects() {
|
async listProjects() {
|
||||||
return [...projects.values()].map(projectSummary);
|
return [...projects.values()].map(projectSummary);
|
||||||
},
|
},
|
||||||
async getWorkspace(_actor, projectRef) {
|
async getWorkspace(_actor, projectRef) {
|
||||||
const project = projects.get(projectRef);
|
return workspace(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),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
async execute(command, actor, input) {
|
async execute(command, actor, input) {
|
||||||
if (command === "owner-scopes:ensure") {
|
if (command === "owner-scopes:ensure") {
|
||||||
@@ -130,6 +198,20 @@ export function createLocalPreviewDeviceCore() {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
projects.set(projectRef, project);
|
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 } };
|
return { replayed: false, result: { created: !existing, project } };
|
||||||
}
|
}
|
||||||
if (command === "collections:ensure") {
|
if (command === "collections:ensure") {
|
||||||
@@ -152,19 +234,241 @@ export function createLocalPreviewDeviceCore() {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
collections.set(collectionRef, collection);
|
collections.set(collectionRef, collection);
|
||||||
|
audit(actor, projectRef, createdEvent(existing, "collection"));
|
||||||
return { replayed: false, result: { created: !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") {
|
if (command === "devices:claim") {
|
||||||
throw serviceError("device_discovery_not_found", 404);
|
throw serviceError("device_discovery_not_found", 404);
|
||||||
}
|
}
|
||||||
throw serviceError("device_manager_command_invalid", 404);
|
throw serviceError("device_manager_command_invalid", 404);
|
||||||
},
|
},
|
||||||
snapshot() {
|
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([
|
const ownerCapabilities = Object.freeze([
|
||||||
"project.read",
|
"project.read",
|
||||||
"project.manage",
|
"project.manage",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const actor = Object.freeze({
|
|||||||
groupRefs: ["group:device-engineers"],
|
groupRefs: ["group:device-engineers"],
|
||||||
ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }],
|
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 () => {
|
test("Device Core client creates trusted actor headers and keeps its token server-side", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -87,11 +88,83 @@ test("local preview is empty and creates resources only through canonical comman
|
|||||||
description: null,
|
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);
|
const projects = await client.listProjects(actor);
|
||||||
assert.equal(projects.length, 1);
|
assert.equal(projects.length, 1);
|
||||||
assert.equal(projects[0].counts.collections, 1);
|
assert.equal(projects[0].counts.collections, 1);
|
||||||
const workspace = await client.getWorkspace(actor, projectRef);
|
const workspace = await client.getWorkspace(actor, projectRef);
|
||||||
assert.equal(workspace.collections[0].collectionKey, "field-devices");
|
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, []);
|
assert.deepEqual(workspace.devices, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,24 @@ const mutationRoutes = new Map([
|
|||||||
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
||||||
["/api/device-manager/projects:ensure", "projects:ensure"],
|
["/api/device-manager/projects:ensure", "projects:ensure"],
|
||||||
["/api/device-manager/collections:ensure", "collections: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/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({
|
export function createDeviceManagerServer({
|
||||||
|
|||||||
@@ -52,11 +52,36 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a
|
|||||||
name: "Pilot devices",
|
name: "Pilot devices",
|
||||||
description: null,
|
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(
|
const workspace = await getJson(
|
||||||
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
||||||
);
|
);
|
||||||
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
||||||
assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices");
|
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, []);
|
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`, {
|
||||||
|
|||||||
@@ -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,
|
ProjectSummary,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import {
|
||||||
|
DeviceControlView,
|
||||||
|
type ControlViewId,
|
||||||
|
} from "./DeviceControlViews";
|
||||||
|
|
||||||
type ViewId = "overview" | "inventory" | "discovery" | "collections";
|
type ViewId =
|
||||||
|
| "overview"
|
||||||
|
| "inventory"
|
||||||
|
| "discovery"
|
||||||
|
| "collections"
|
||||||
|
| ControlViewId;
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ id: "overview", label: "Обзор", icon: "grid" },
|
{ id: "overview", label: "Обзор", icon: "grid", capability: null },
|
||||||
{ id: "inventory", label: "Устройства", icon: "apps" },
|
{ id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" },
|
||||||
{ id: "discovery", label: "Подключение", icon: "network" },
|
{ id: "discovery", label: "Подключение", icon: "network", capability: "inventory.read" },
|
||||||
{ id: "collections", label: "Коллекции", icon: "folder" },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
export function DeviceManagerApp() {
|
export function DeviceManagerApp() {
|
||||||
@@ -115,6 +132,9 @@ export function DeviceManagerApp() {
|
|||||||
);
|
);
|
||||||
const canManageCollections = capabilities.has("collection.manage");
|
const canManageCollections = capabilities.has("collection.manage");
|
||||||
const canClaim = capabilities.has("device.claim");
|
const canClaim = capabilities.has("device.claim");
|
||||||
|
const visibleNavigationItems = navigationItems.filter(
|
||||||
|
(item) => item.capability === null || capabilities.has(item.capability),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
|
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
|
||||||
@@ -227,7 +247,7 @@ export function DeviceManagerApp() {
|
|||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
items={activeProject ? navigationItems.map((item) => ({
|
items={activeProject ? visibleNavigationItems.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
icon: <Icon name={item.icon} />,
|
icon: <Icon name={item.icon} />,
|
||||||
@@ -260,6 +280,9 @@ export function DeviceManagerApp() {
|
|||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
canManageCollections={canManageCollections}
|
canManageCollections={canManageCollections}
|
||||||
canClaim={canClaim}
|
canClaim={canClaim}
|
||||||
|
session={session}
|
||||||
|
onRefresh={refreshWorkspace}
|
||||||
|
onError={(reason) => setError(errorText(reason))}
|
||||||
onCreateCollection={() => setCollectionDialogOpen(true)}
|
onCreateCollection={() => setCollectionDialogOpen(true)}
|
||||||
onClaim={setClaimEnrollment}
|
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;
|
view: ViewId;
|
||||||
workspace: ProjectWorkspace | null;
|
workspace: ProjectWorkspace | null;
|
||||||
canManageCollections: boolean;
|
canManageCollections: boolean;
|
||||||
canClaim: boolean;
|
canClaim: boolean;
|
||||||
|
session: DeviceManagerSession;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
onError: (reason: unknown) => void;
|
||||||
onCreateCollection: () => void;
|
onCreateCollection: () => void;
|
||||||
onClaim: (enrollment: EnrollmentView) => void;
|
onClaim: (enrollment: EnrollmentView) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
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 (
|
if (view === "inventory") return (
|
||||||
<EntityList
|
<EntityList
|
||||||
empty="В проекте ещё нет зарегистрированных устройств."
|
empty="В проекте ещё нет зарегистрированных устройств."
|
||||||
@@ -616,7 +651,20 @@ function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewTitle(view: ViewId) {
|
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) {
|
function errorText(reason: unknown) {
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AdapterPackageView,
|
||||||
|
AdapterVersionView,
|
||||||
|
BindingView,
|
||||||
|
ConfigurationRevisionView,
|
||||||
|
EdgeView,
|
||||||
|
ModelProfileView,
|
||||||
|
ProjectGrantView,
|
||||||
DeviceManagerSession,
|
DeviceManagerSession,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
ProjectWorkspace,
|
ProjectWorkspace,
|
||||||
|
RouteView,
|
||||||
ScopeKind,
|
ScopeKind,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -60,8 +68,144 @@ export async function claimDevice(input: {
|
|||||||
return mutate("/api/device-manager/devices:claim", input);
|
return mutate("/api/device-manager/devices:claim", input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mutate(path: string, input: unknown) {
|
export async function upsertProjectGrant(input: {
|
||||||
return requestJson<{ ok: true; replayed: boolean; result: unknown }>(path, {
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -3,6 +3,161 @@
|
|||||||
background: #0b0d0f;
|
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,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
@@ -94,10 +94,184 @@ export interface EnrollmentView {
|
|||||||
updatedAt: string | null;
|
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 {
|
export interface ProjectWorkspace {
|
||||||
project: ProjectSummary;
|
project: ProjectSummary;
|
||||||
devices: DeviceView[];
|
devices: DeviceView[];
|
||||||
collections: CollectionView[];
|
collections: CollectionView[];
|
||||||
discoveries: DiscoveryView[];
|
discoveries: DiscoveryView[];
|
||||||
enrollments: EnrollmentView[];
|
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