From 1c5246afe8595deaaacd7ac8228046858d831d70 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 10 Aug 2026 20:49:16 +0300 Subject: [PATCH] feat(device-manager): add control and audit workspaces --- apps/device-manager/README.md | 14 +- .../server/device-core-client.mjs | 328 +++++++- .../server/device-core-client.test.mjs | 73 ++ .../server/device-manager-server.mjs | 17 + .../server/device-manager-server.test.mjs | 25 + .../device-manager/src/DeviceControlViews.tsx | 795 ++++++++++++++++++ apps/device-manager/src/DeviceManagerApp.tsx | 64 +- apps/device-manager/src/api.ts | 148 +++- apps/device-manager/src/styles.css | 155 ++++ apps/device-manager/src/types.ts | 174 ++++ 10 files changed, 1769 insertions(+), 24 deletions(-) create mode 100644 apps/device-manager/src/DeviceControlViews.tsx diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md index 8f81539..c57f78a 100644 --- a/apps/device-manager/README.md +++ b/apps/device-manager/README.md @@ -14,8 +14,18 @@ behavior; projects, inventory, collections and access remain shared Device Core - The BFF reads the Core bearer token from `NODEDC_DEVICE_CORE_TOKEN_FILE`; the token is never embedded into client assets or accepted as a raw environment value. - Device Control Core owns authorization, lifecycle validation, idempotency and persistence. -- Query responses contain masked identifiers only. Digests and credential references stay - inside Device Control Core. +- Query responses contain masked identifiers and bounded metadata only. Identifier and + credential digests, external approval proofs, command parameters/transport refs, raw + configuration documents and audit payloads stay inside Device Control Core. + +The project workspace covers inventory, discovery, collections, adapter/profile metadata, +Edges, routes, sessions, bindings, configuration state, the honest command ledger, immutable +audit metadata and project grants. Navigation and actions are derived from effective project +capabilities. Global adapter/profile/Edge mutation is additionally restricted to a Hub owner. + +Command planning and transport intentionally have no Device Manager mutation route yet. +The UI never presents `sent` as success: `acknowledged` and `verified` remain different +ledger states, and the disabled transport policy is visible in the Commands section. Hub currently supplies identity and groups but no signed company-membership/owner-scope claim. Therefore an admin may create projects in their personal scope. Existing company diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs index cc94cb6..58ba12d 100644 --- a/apps/device-manager/server/device-core-client.mjs +++ b/apps/device-manager/server/device-core-client.mjs @@ -4,7 +4,24 @@ const commandRoutes = new Map([ ["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"], ["projects:ensure", "/internal/v1/management/projects:ensure"], ["collections:ensure", "/internal/v1/management/collections:ensure"], + ["project-grants:upsert", "/internal/v1/management/project-grants:upsert"], + ["adapter-packages:ensure", "/internal/v1/management/adapter-packages:ensure"], + ["adapter-versions:register", "/internal/v1/management/adapter-versions:register"], + ["model-profiles:register", "/internal/v1/management/model-profiles:register"], + ["edges:ensure", "/internal/v1/management/edges:ensure"], + ["routes:ensure", "/internal/v1/management/routes:ensure"], + ["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"], ["devices:claim", "/internal/v1/management/devices:claim"], + ["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"], + ["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"], + [ + "device-configuration-revisions:create", + "/internal/v1/management/device-configuration-revisions:create", + ], + [ + "device-configurations:set-desired", + "/internal/v1/management/device-configurations:set-desired", + ], ]); export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) { @@ -67,6 +84,36 @@ export function createLocalPreviewDeviceCore() { const ownerScopes = new Map(); const projects = new Map(); const collections = new Map(); + const adapterPackages = new Map(); + const adapterVersions = new Map(); + const modelProfiles = new Map(); + const edges = new Map(); + const routes = new Map(); + const bindings = new Map(); + const grants = new Map(); + const configurationRevisions = new Map(); + const configurationStates = new Map(); + const auditEvents = []; + + function now() { + return new Date().toISOString(); + } + + function projectValues(store, projectRef) { + return [...store.values()].filter((value) => value.projectRef === projectRef); + } + + function audit(actor, projectRef, eventType, refs = {}) { + auditEvents.unshift({ + auditEventRef: `audit-event:${randomUUID()}`, + eventType, + actorRef: actor.userRef, + deviceRef: refs.deviceRef ?? null, + discoveryRef: refs.discoveryRef ?? null, + projectRef, + occurredAt: now(), + }); + } function projectSummary(project) { const projectCollections = [...collections.values()] @@ -77,23 +124,44 @@ export function createLocalPreviewDeviceCore() { }; } + function workspace(projectRef) { + const project = projects.get(projectRef); + if (!project) throw serviceError("device_project_not_found", 404); + return { + project: projectSummary(project), + devices: [], + discoveries: [], + enrollments: [], + collections: projectValues(collections, projectRef) + .map(({ projectRef: _projectRef, ...collection }) => collection), + adapterPackages: [...adapterPackages.values()], + adapterVersions: [...adapterVersions.values()], + modelProfiles: [...modelProfiles.values()], + edges: [...edges.values()], + routes: projectValues(routes, projectRef), + sessions: [], + bindings: projectValues(bindings, projectRef), + configurationRevisions: projectValues(configurationRevisions, projectRef), + configurationStates: projectValues(configurationStates, projectRef), + commands: [], + auditEvents: auditEvents.filter((event) => event.projectRef === projectRef), + grants: projectValues(grants, projectRef), + policies: { + commandTransport: "disabled", + commandPlanningApi: "disabled", + identifierProjection: "masked-only", + auditPayloadProjection: "metadata-only", + }, + }; + } + return { configured: true, async listProjects() { return [...projects.values()].map(projectSummary); }, async getWorkspace(_actor, projectRef) { - const project = projects.get(projectRef); - if (!project) throw serviceError("device_project_not_found", 404); - return { - project: projectSummary(project), - devices: [], - discoveries: [], - enrollments: [], - collections: [...collections.values()] - .filter((collection) => collection.projectRef === projectRef) - .map(({ projectRef: _projectRef, ...collection }) => collection), - }; + return workspace(projectRef); }, async execute(command, actor, input) { if (command === "owner-scopes:ensure") { @@ -130,6 +198,20 @@ export function createLocalPreviewDeviceCore() { updatedAt: new Date().toISOString(), }; projects.set(projectRef, project); + if (!existing) { + const grantRef = `grant:${randomUUID()}`; + grants.set(grantRef, { + grantRef, + projectRef, + principalKind: "user", + principalRef: actor.userRef, + projectRole: "owner", + capabilityAllow: [], + capabilityDeny: [], + lifecycleState: "active", + }); + audit(actor, projectRef, "project.created"); + } return { replayed: false, result: { created: !existing, project } }; } if (command === "collections:ensure") { @@ -152,19 +234,241 @@ export function createLocalPreviewDeviceCore() { updatedAt: new Date().toISOString(), }; collections.set(collectionRef, collection); + audit(actor, projectRef, createdEvent(existing, "collection")); return { replayed: false, result: { created: !existing, collection } }; } + if (command === "project-grants:upsert") { + if (!projects.has(input.projectRef)) { + throw serviceError("device_project_not_found", 404); + } + const existing = [...grants.values()].find((grant) => + grant.projectRef === input.projectRef + && grant.principalKind === input.principalKind + && grant.principalRef === input.principalRef + ); + const grantRef = existing?.grantRef || `grant:${randomUUID()}`; + const grant = { + grantRef, + projectRef: input.projectRef, + principalKind: input.principalKind, + principalRef: input.principalRef, + projectRole: input.projectRole, + capabilityAllow: input.capabilityAllow ?? [], + capabilityDeny: input.capabilityDeny ?? [], + lifecycleState: input.lifecycleState ?? "active", + }; + grants.set(grantRef, grant); + audit(actor, input.projectRef, createdEvent(existing, "project_grant")); + return { replayed: false, result: { created: !existing, grant } }; + } + if (command === "adapter-packages:ensure") { + requirePlatformOwner(actor); + const existing = [...adapterPackages.values()].find( + (entry) => entry.packageKey === input.packageKey, + ); + const adapterPackageRef = existing?.adapterPackageRef + || `adapter-package:${randomUUID()}`; + const adapterPackage = { + adapterPackageRef, + packageKey: input.packageKey, + displayName: input.displayName, + publisherRef: input.publisherRef, + lifecycleState: input.lifecycleState ?? "active", + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + adapterPackages.set(adapterPackageRef, adapterPackage); + return { replayed: false, result: { created: !existing, adapterPackage } }; + } + if (command === "adapter-versions:register") { + requirePlatformOwner(actor); + if (!adapterPackages.has(input.adapterPackageRef)) { + throw serviceError("device_adapter_package_not_found", 404); + } + const existing = [...adapterVersions.values()].find((entry) => + entry.adapterPackageRef === input.adapterPackageRef + && entry.version === input.version + ); + const adapterVersionRef = existing?.adapterVersionRef + || `adapter-version:${randomUUID()}`; + const adapterVersion = { + adapterVersionRef, + adapterPackageRef: input.adapterPackageRef, + version: input.version, + runtimePackageRef: input.runtimePackageRef, + contentDigest: input.contentDigest, + contractVersion: input.contractVersion, + capabilities: input.capabilities ?? [], + lifecycleState: input.lifecycleState ?? "draft", + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + adapterVersions.set(adapterVersionRef, adapterVersion); + return { replayed: false, result: { created: !existing, adapterVersion } }; + } + if (command === "model-profiles:register") { + requirePlatformOwner(actor); + if (!adapterVersions.has(input.adapterVersionRef)) { + throw serviceError("device_adapter_version_not_found", 404); + } + const existing = modelProfiles.get(input.profileRef); + const modelProfile = { + modelProfileRef: input.profileRef, + adapterVersionRef: input.adapterVersionRef, + schemaVersion: input.schemaVersion, + vendor: input.vendor, + model: input.model, + deviceType: input.deviceType, + protocol: input.protocol, + schemaArtifactRef: input.schemaArtifactRef, + profileDigest: input.profileDigest, + capabilities: input.capabilities ?? [], + lifecycleState: input.lifecycleState ?? "draft", + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + modelProfiles.set(input.profileRef, modelProfile); + return { replayed: false, result: { created: !existing, modelProfile } }; + } + if (command === "edges:ensure") { + requirePlatformOwner(actor); + const existing = [...edges.values()].find( + (entry) => entry.edgeKey === input.edgeKey, + ); + const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`; + const edge = { + edgeRef, + edgeKey: input.edgeKey, + displayName: input.displayName, + deploymentRef: input.deploymentRef ?? null, + lifecycleState: input.lifecycleState ?? "provisioning", + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + edges.set(edgeRef, edge); + return { replayed: false, result: { created: !existing, edge } }; + } + if (command === "routes:ensure") { + if (!projects.has(input.projectRef)) { + throw serviceError("device_project_not_found", 404); + } + const edge = edges.get(input.edgeRef); + const profile = modelProfiles.get(input.modelProfileRef); + if (!edge) throw serviceError("device_edge_not_found", 404); + if (!profile) throw serviceError("device_model_profile_not_found", 404); + const existing = projectValues(routes, input.projectRef).find( + (entry) => entry.routeKey === input.routeKey, + ); + const routeRef = existing?.routeRef || `route:${randomUUID()}`; + const route = { + routeRef, + projectRef: input.projectRef, + routeKey: input.routeKey, + displayName: input.displayName, + edgeRef: input.edgeRef, + edgeName: edge.displayName, + modelProfileRef: input.modelProfileRef, + profileName: `${profile.vendor} ${profile.model}`, + listenerRef: input.listenerRef, + protocol: input.protocol, + direction: input.direction ?? "telemetry", + lifecycleState: input.lifecycleState ?? "draft", + sessionCount: 0, + activeSessionCount: 0, + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + routes.set(routeRef, route); + audit(actor, input.projectRef, createdEvent(existing, "route")); + return { replayed: false, result: { created: !existing, route } }; + } + if (command === "device-bindings:ensure") { + if (!projects.has(input.projectRef)) { + throw serviceError("device_project_not_found", 404); + } + if (input.source?.kind !== "collection" || !collections.has(input.source.ref)) { + throw serviceError("device_binding_source_not_found", 404); + } + const existing = projectValues(bindings, input.projectRef).find( + (entry) => entry.bindingKey === input.bindingKey, + ); + const bindingRef = existing?.bindingRef || `binding:${randomUUID()}`; + const source = collections.get(input.source.ref); + const binding = { + bindingRef, + projectRef: input.projectRef, + bindingKey: input.bindingKey, + displayName: input.displayName, + source: { + kind: input.source.kind, + ref: input.source.ref, + displayName: source.name, + }, + target: { kind: input.targetKind, ref: input.targetRef }, + capabilities: input.capabilities, + lifecycleState: "pending_external_approval", + sourceApprovedAt: now(), + createdAt: existing?.createdAt || now(), + updatedAt: now(), + }; + bindings.set(bindingRef, binding); + audit(actor, input.projectRef, createdEvent(existing, "device_binding")); + return { replayed: false, result: { created: !existing, binding } }; + } + if (command === "device-bindings:revoke") { + const binding = bindings.get(input.bindingRef); + if (!binding || binding.projectRef !== input.projectRef) { + throw serviceError("device_binding_not_found", 404); + } + const revoked = { ...binding, lifecycleState: "revoked", updatedAt: now() }; + bindings.set(binding.bindingRef, revoked); + audit(actor, input.projectRef, "device_binding.revoked"); + return { replayed: false, result: { revoked: true, binding: revoked } }; + } + if (command === "device-configuration-revisions:create") { + throw serviceError("device_not_found", 404); + } + if (command === "device-configurations:set-desired") { + throw serviceError("device_configuration_revision_not_found", 404); + } + if (command === "enrollment-intents:ensure") { + throw serviceError("device_enrollment_secure_input_required", 409); + } if (command === "devices:claim") { throw serviceError("device_discovery_not_found", 404); } throw serviceError("device_manager_command_invalid", 404); }, snapshot() { - return { ownerScopes, projects, collections }; + return { + ownerScopes, + projects, + collections, + adapterPackages, + adapterVersions, + modelProfiles, + edges, + routes, + bindings, + grants, + configurationRevisions, + configurationStates, + auditEvents, + }; }, }; } +function createdEvent(existing, resource) { + return `${resource}.${existing ? "updated" : "created"}`; +} + +function requirePlatformOwner(actor) { + if (actor?.hubRole !== "owner") { + throw serviceError("device_platform_catalog_access_denied", 403); + } +} + const ownerCapabilities = Object.freeze([ "project.read", "project.manage", diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs index d71c0c7..69d4142 100644 --- a/apps/device-manager/server/device-core-client.test.mjs +++ b/apps/device-manager/server/device-core-client.test.mjs @@ -13,6 +13,7 @@ const actor = Object.freeze({ groupRefs: ["group:device-engineers"], ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }], }); +const platformActor = Object.freeze({ ...actor, hubRole: "owner" }); test("Device Core client creates trusted actor headers and keeps its token server-side", async () => { const calls = []; @@ -87,11 +88,83 @@ test("local preview is empty and creates resources only through canonical comman description: null, }); + const adapterPackage = await client.execute("adapter-packages:ensure", platformActor, { + packageKey: "generic-tracker", + displayName: "Generic tracker", + publisherRef: "publisher:nodedc", + lifecycleState: "active", + }); + const adapterVersion = await client.execute("adapter-versions:register", platformActor, { + adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef, + version: "1.0.0", + runtimePackageRef: "artifact:generic-tracker:1.0.0", + contentDigest: `sha256:${"a".repeat(64)}`, + contractVersion: "device-adapter.v1", + capabilities: ["telemetry"], + lifecycleState: "draft", + }); + const modelProfile = await client.execute("model-profiles:register", platformActor, { + adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef, + profileRef: "generic.tracker.v1", + schemaVersion: "1.0.0", + vendor: "Generic", + model: "Tracker", + deviceType: "tracker", + protocol: "INTERNAL", + schemaArtifactRef: "schema:generic.tracker.v1", + profileDigest: `sha256:${"b".repeat(64)}`, + capabilities: ["telemetry"], + lifecycleState: "draft", + }); + const edge = await client.execute("edges:ensure", platformActor, { + edgeKey: "preview-edge", + displayName: "Preview Edge", + deploymentRef: "deployment:preview-edge", + lifecycleState: "provisioning", + }); + await client.execute("routes:ensure", actor, { + projectRef, + routeKey: "preview-route", + displayName: "Preview route", + edgeRef: edge.result.edge.edgeRef, + modelProfileRef: modelProfile.result.modelProfile.modelProfileRef, + listenerRef: "listener:preview", + protocol: "INTERNAL", + direction: "telemetry", + lifecycleState: "draft", + }); + const collectionRef = (await client.getWorkspace(actor, projectRef)) + .collections[0].collectionRef; + await client.execute("device-bindings:ensure", actor, { + projectRef, + bindingKey: "preview-binding", + displayName: "Preview binding", + source: { kind: "collection", ref: collectionRef }, + targetKind: "foundry.application", + targetRef: "application:preview-map", + capabilities: ["observe"], + }); + await client.execute("project-grants:upsert", actor, { + projectRef, + principalKind: "group", + principalRef: "group:preview-viewers", + projectRole: "viewer", + capabilityAllow: [], + capabilityDeny: [], + lifecycleState: "active", + }); + const projects = await client.listProjects(actor); assert.equal(projects.length, 1); assert.equal(projects[0].counts.collections, 1); const workspace = await client.getWorkspace(actor, projectRef); assert.equal(workspace.collections[0].collectionKey, "field-devices"); + assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker"); + assert.equal(workspace.routes[0].routeKey, "preview-route"); + assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval"); + assert.equal(workspace.grants.length, 2); + assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created")); + assert.equal(workspace.policies.commandTransport, "disabled"); assert.deepEqual(workspace.devices, []); }); diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs index 0a5f8c7..4d46fb6 100644 --- a/apps/device-manager/server/device-manager-server.mjs +++ b/apps/device-manager/server/device-manager-server.mjs @@ -15,7 +15,24 @@ const mutationRoutes = new Map([ ["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"], ["/api/device-manager/projects:ensure", "projects:ensure"], ["/api/device-manager/collections:ensure", "collections:ensure"], + ["/api/device-manager/project-grants:upsert", "project-grants:upsert"], + ["/api/device-manager/adapter-packages:ensure", "adapter-packages:ensure"], + ["/api/device-manager/adapter-versions:register", "adapter-versions:register"], + ["/api/device-manager/model-profiles:register", "model-profiles:register"], + ["/api/device-manager/edges:ensure", "edges:ensure"], + ["/api/device-manager/routes:ensure", "routes:ensure"], + ["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"], ["/api/device-manager/devices:claim", "devices:claim"], + ["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"], + ["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"], + [ + "/api/device-manager/device-configuration-revisions:create", + "device-configuration-revisions:create", + ], + [ + "/api/device-manager/device-configurations:set-desired", + "device-configurations:set-desired", + ], ]); export function createDeviceManagerServer({ diff --git a/apps/device-manager/server/device-manager-server.test.mjs b/apps/device-manager/server/device-manager-server.test.mjs index 8cee497..65d81c2 100644 --- a/apps/device-manager/server/device-manager-server.test.mjs +++ b/apps/device-manager/server/device-manager-server.test.mjs @@ -52,11 +52,36 @@ test("Device Manager BFF exposes an empty, mutation-driven project workspace", a name: "Pilot devices", description: null, }); + await postJson(`${baseUrl}/api/device-manager/adapter-packages:ensure`, { + packageKey: "generic-sensor", + displayName: "Generic sensor", + publisherRef: "publisher:nodedc", + lifecycleState: "active", + }); + await postJson(`${baseUrl}/api/device-manager/edges:ensure`, { + edgeKey: "preview-edge", + displayName: "Preview Edge", + deploymentRef: "deployment:preview-edge", + lifecycleState: "provisioning", + }); + await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, { + projectRef, + principalKind: "group", + principalRef: "group:preview-viewers", + projectRole: "viewer", + capabilityAllow: [], + capabilityDeny: [], + lifecycleState: "active", + }); const workspace = await getJson( `${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`, ); assert.equal(workspace.workspace.project.projectRef, projectRef); assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices"); + assert.equal(workspace.workspace.adapterPackages[0].packageKey, "generic-sensor"); + assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge"); + assert.equal(workspace.workspace.grants.length, 2); + assert.equal(workspace.workspace.policies.commandTransport, "disabled"); assert.deepEqual(workspace.workspace.devices, []); const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, { diff --git a/apps/device-manager/src/DeviceControlViews.tsx b/apps/device-manager/src/DeviceControlViews.tsx new file mode 100644 index 0000000..8095aeb --- /dev/null +++ b/apps/device-manager/src/DeviceControlViews.tsx @@ -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; + onError: (reason: unknown) => void; +}) { + const [dialog, setDialog] = useState(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" ? ( + setDialog("adapter-package")} + onCreateVersion={() => setDialog("adapter-version")} + onCreateProfile={() => setDialog("model-profile")} + /> + ) : null} + {view === "infrastructure" ? ( + setDialog("edge")} + onCreateRoute={() => setDialog("route")} + /> + ) : null} + {view === "sessions" ? : null} + {view === "bindings" ? ( + setDialog("binding")} + onRevoke={(binding) => revokeDeviceBinding({ + projectRef: workspace.project.projectRef, + bindingRef: binding.bindingRef, + resolutionCode: "operator.revoked", + }).then(onRefresh).catch(onError)} + /> + ) : null} + {view === "commands" ? : null} + {view === "audit" ? : null} + {view === "access" ? ( + setDialog("grant")} + /> + ) : null} + {view === "settings" ? ( + setDialog("configuration")} + /> + ) : null} + + + + + + + + + + + ); +} + +function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile }: { + workspace: ProjectWorkspace; + canManage: boolean; + onCreatePackage: () => void; + onCreateVersion: () => void; + onCreateProfile: () => void; +}) { + return ( + + + + + + : null} + /> + + + {workspace.modelProfiles.map((profile) => ( + + ))} + + + + + {workspace.adapterPackages.map((adapterPackage) => ( + version.adapterPackageRef === adapterPackage.adapterPackageRef) + .map((version) => `${version.version} · ${version.lifecycleState}`)} + /> + ))} + + + + ); +} + +function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute }: { + workspace: ProjectWorkspace; + canManageCatalog: boolean; + canManageRoutes: boolean; + onCreateEdge: () => void; + onCreateRoute: () => void; +}) { + return ( + + + {canManageCatalog ? : null} + {canManageRoutes ? : null} + } + /> + + + {workspace.routes.map((route) => ( + + ))} + + + + + {workspace.edges.map((edge) => ( + + ))} + + + + ); +} + +function SessionsView({ workspace }: { workspace: ProjectWorkspace }) { + return ( + + + + {workspace.sessions.map((session) => ( + + ))} + + + ); +} + +function BindingsView({ workspace, canManage, onCreate, onRevoke }: { + workspace: ProjectWorkspace; + canManage: boolean; + onCreate: () => void; + onRevoke: (binding: BindingView) => void; +}) { + return ( + + Новый binding : null} + /> + + {workspace.bindings.map((binding) => ( + onRevoke(binding)}>Отозвать + ) : binding.capabilities.join(", ")} + /> + ))} + + + ); +} + +function CommandsView({ workspace }: { workspace: ProjectWorkspace }) { + return ( + +
+ +
+ Command transport выключен +

Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния.

+
+ {workspace.policies.commandTransport} +
+ + {workspace.commands.map((command) => ( + + ))} + +
+ ); +} + +function AuditView({ workspace }: { workspace: ProjectWorkspace }) { + return ( + + + + {workspace.auditEvents.map((event) => ( + + ))} + + + ); +} + +function AccessView({ workspace, canManage, onCreate }: { + workspace: ProjectWorkspace; + canManage: boolean; + onCreate: () => void; +}) { + return ( + + Добавить доступ : null} + /> + + {workspace.grants.map((grant) => ( + + ))} + + + ); +} + +function SettingsView({ workspace, canConfigure, onCreateConfiguration }: { + workspace: ProjectWorkspace; + canConfigure: boolean; + onCreateConfiguration: () => void; +}) { + return ( + +
+ + + +
+ Новая desired revision : null} + /> + + {workspace.configurationStates.map((state) => ( + + ))} + + + + {workspace.configurationRevisions.map((revision) => ( + + ))} + + +
+ ); +} + +function AdapterPackageDialog(props: DialogBaseProps) { + const [packageKey, setPackageKey] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [publisherRef, setPublisherRef] = useState(""); + return { + await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" }); + }}> + + setDisplayName(event.target.value)} required /> + setPublisherRef(event.target.value)} required /> + ; +} + +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 { + await registerAdapterVersion({ + adapterPackageRef: packageRef, + version, + runtimePackageRef: runtimeRef, + contentDigest: digest, + contractVersion, + capabilities: commaList(capabilities), + lifecycleState: "draft", + }); + }}> + ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} /> + setProfileRef(event.target.value)} required /> + setSchemaVersion(event.target.value)} required /> + setVendor(event.target.value)} required /> + setModel(event.target.value)} required /> + + setProtocol(event.target.value)} required /> + setSchemaRef(event.target.value)} required /> + setDigest(event.target.value)} required placeholder="sha256:…" /> + setCapabilities(event.target.value)} /> + ; +} + +function EdgeDialog(props: DialogBaseProps) { + const [edgeKey, setEdgeKey] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [deploymentRef, setDeploymentRef] = useState(""); + return { + await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" }); + }}> + + setDisplayName(event.target.value)} required /> + setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." /> + ; +} + +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 { + await ensureRoute({ + projectRef: workspace.project.projectRef, + routeKey, + displayName, + edgeRef, + modelProfileRef: profileRef, + listenerRef, + protocol: profile?.protocol || "INTERNAL", + direction: "telemetry", + lifecycleState: "draft", + }); + }}> + + setDisplayName(event.target.value)} required /> + ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} /> + setListenerRef(event.target.value)} required /> +

Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.

+
; +} + +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 { + 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), + }); + }}> + + setPrincipalRef(event.target.value)} required /> + ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} /> + setConfiguration(event.target.value)} required /> + setSummary(event.target.value)} /> +

Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.

+
; +} + +interface DialogBaseProps { + open: boolean; + onClose: () => void; + onCreated: () => Promise; + onError: (reason: unknown) => void; +} + +function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & { + id: string; + title: string; + submit: () => Promise; + 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 ( + + + + + }> +
{children}
+
+ ); +} + +function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { + return onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />; +} + +function ControlStack({ children }: { children: ReactNode }) { + return
{children}
; +} + +function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) { + return

{copy}

{actions ?
{actions}
: null}
; +} + +function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) { + return

{title}

{count}
{children}
; +} + +function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) { + const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children); + return hasChildren ?
{children}
:
{empty}
; +} + +function ResourceCard({ eyebrow, title, description, status, meta }: { eyebrow: string; title: string; description: string; status: string; meta: string[] }) { + return {status}}> + {meta.length ?
{meta.map((item) => {item})}
:

Metadata-only projection

} +
; +} + +function ResourceList({ children, empty }: { children: ReactNode; empty: string }) { + const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children); + return hasChildren ?
{children}
:
{empty}
; +} + +function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) { + return + + {title}{description} + {status}{typeof trailing === "string" ? {trailing} : trailing} + ; +} + +function PolicyCard({ label, value }: { label: string; value: string }) { + return {label}{value}; +} + +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)}`; +} diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx index d6babc8..e00af8f 100644 --- a/apps/device-manager/src/DeviceManagerApp.tsx +++ b/apps/device-manager/src/DeviceManagerApp.tsx @@ -36,14 +36,31 @@ import type { ProjectSummary, ProjectWorkspace, } from "./types"; +import { + DeviceControlView, + type ControlViewId, +} from "./DeviceControlViews"; -type ViewId = "overview" | "inventory" | "discovery" | "collections"; +type ViewId = + | "overview" + | "inventory" + | "discovery" + | "collections" + | ControlViewId; const navigationItems = [ - { id: "overview", label: "Обзор", icon: "grid" }, - { id: "inventory", label: "Устройства", icon: "apps" }, - { id: "discovery", label: "Подключение", icon: "network" }, - { id: "collections", label: "Коллекции", icon: "folder" }, + { id: "overview", label: "Обзор", icon: "grid", capability: null }, + { id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" }, + { id: "discovery", label: "Подключение", icon: "network", capability: "inventory.read" }, + { id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" }, + { id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" }, + { id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" }, + { id: "sessions", label: "Сессии", icon: "activity", capability: "telemetry.observe" }, + { id: "bindings", label: "Bindings", icon: "external", capability: "binding.manage" }, + { id: "commands", label: "Команды", icon: "target", capability: "command.plan" }, + { id: "audit", label: "Аудит", icon: "clipboard", capability: "audit.read" }, + { id: "access", label: "Доступ", icon: "users", capability: "access.manage" }, + { id: "settings", label: "Настройки", icon: "settings", capability: "configuration.read" }, ] as const; export function DeviceManagerApp() { @@ -115,6 +132,9 @@ export function DeviceManagerApp() { ); const canManageCollections = capabilities.has("collection.manage"); const canClaim = capabilities.has("device.claim"); + const visibleNavigationItems = navigationItems.filter( + (item) => item.capability === null || capabilities.has(item.capability), + ); useEffect(() => { if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef); @@ -227,7 +247,7 @@ export function DeviceManagerApp() { /> ) : null } - items={activeProject ? navigationItems.map((item) => ({ + items={activeProject ? visibleNavigationItems.map((item) => ({ id: item.id, label: item.label, icon: , @@ -260,6 +280,9 @@ export function DeviceManagerApp() { workspace={workspace} canManageCollections={canManageCollections} canClaim={canClaim} + session={session} + onRefresh={refreshWorkspace} + onError={(reason) => setError(errorText(reason))} onCreateCollection={() => setCollectionDialogOpen(true)} onClaim={setClaimEnrollment} /> @@ -420,15 +443,27 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val ); } -function ProjectView({ view, workspace, canManageCollections, canClaim, onCreateCollection, onClaim }: { +function ProjectView({ view, workspace, canManageCollections, canClaim, session, onRefresh, onError, onCreateCollection, onClaim }: { view: ViewId; workspace: ProjectWorkspace | null; canManageCollections: boolean; canClaim: boolean; + session: DeviceManagerSession; + onRefresh: () => Promise; + onError: (reason: unknown) => void; onCreateCollection: () => void; onClaim: (enrollment: EnrollmentView) => void; }) { if (!workspace) return
Загружаем проект…
; + if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) { + return ; + } if (view === "inventory") return ( (path, { +export async function upsertProjectGrant(input: { + projectRef: string; + principalKind: "user" | "group"; + principalRef: string; + projectRole: string; + capabilityAllow: string[]; + capabilityDeny: string[]; + lifecycleState: "active" | "revoked"; +}) { + return mutate<{ created: boolean; grant: ProjectGrantView }>( + "/api/device-manager/project-grants:upsert", + input, + ); +} + +export async function ensureAdapterPackage(input: { + packageKey: string; + displayName: string; + publisherRef: string; + lifecycleState: "active" | "retired"; +}) { + return mutate<{ created: boolean; adapterPackage: AdapterPackageView }>( + "/api/device-manager/adapter-packages:ensure", + input, + ); +} + +export async function registerAdapterVersion(input: { + adapterPackageRef: string; + version: string; + runtimePackageRef: string; + contentDigest: string; + contractVersion: string; + capabilities: string[]; + lifecycleState: "draft" | "active" | "retired"; +}) { + return mutate<{ created: boolean; adapterVersion: AdapterVersionView }>( + "/api/device-manager/adapter-versions:register", + input, + ); +} + +export async function registerModelProfile(input: { + adapterVersionRef: string; + profileRef: string; + schemaVersion: string; + vendor: string; + model: string; + deviceType: string; + protocol: string; + schemaArtifactRef: string; + profileDigest: string; + capabilities: string[]; + lifecycleState: "draft" | "active" | "retired"; +}) { + return mutate<{ created: boolean; modelProfile: ModelProfileView }>( + "/api/device-manager/model-profiles:register", + input, + ); +} + +export async function ensureEdge(input: { + edgeKey: string; + displayName: string; + deploymentRef: string | null; + lifecycleState: "provisioning" | "active" | "suspended" | "retired"; +}) { + return mutate<{ created: boolean; edge: EdgeView }>( + "/api/device-manager/edges:ensure", + input, + ); +} + +export async function ensureRoute(input: { + projectRef: string; + routeKey: string; + displayName: string; + edgeRef: string; + modelProfileRef: string; + listenerRef: string; + protocol: string; + direction: "telemetry" | "bidirectional"; + lifecycleState: "draft" | "active" | "suspended" | "retired"; +}) { + return mutate<{ created: boolean; route: RouteView }>( + "/api/device-manager/routes:ensure", + input, + ); +} + +export async function ensureDeviceBinding(input: { + projectRef: string; + bindingKey: string; + displayName: string; + source: { kind: "device" | "collection"; ref: string }; + targetKind: string; + targetRef: string; + capabilities: string[]; +}) { + return mutate<{ created: boolean; binding: BindingView }>( + "/api/device-manager/device-bindings:ensure", + input, + ); +} + +export async function revokeDeviceBinding(input: { + projectRef: string; + bindingRef: string; + resolutionCode: string; +}) { + return mutate<{ revoked: boolean; binding: BindingView }>( + "/api/device-manager/device-bindings:revoke", + input, + ); +} + +export async function createConfigurationRevision(input: { + projectRef: string; + deviceRef: string; + configuration: Record; + 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(path: string, input: unknown) { + return requestJson<{ ok: true; replayed: boolean; result: T }>(path, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/device-manager/src/styles.css b/apps/device-manager/src/styles.css index 45975a9..5428755 100644 --- a/apps/device-manager/src/styles.css +++ b/apps/device-manager/src/styles.css @@ -3,6 +3,161 @@ background: #0b0d0f; } +.device-control-stack { + gap: 22px; +} + +.device-control-toolbar { + align-items: center; + padding: 2px 0 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); +} + +.device-control-toolbar > p { + max-width: 720px; + margin: 0; + color: rgba(255, 255, 255, 0.6); + line-height: 1.55; +} + +.device-control-toolbar__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.device-control-section { + display: grid; + gap: 12px; +} + +.device-control-section__title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.device-control-section__title h3 { + margin: 0; + font-size: 15px; + letter-spacing: -0.01em; +} + +.device-control-resource-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.device-control-row { + min-width: 0; +} + +.device-control-row__status { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} + +.device-control-row__status small { + max-width: 260px; + overflow: hidden; + color: rgba(255, 255, 255, 0.45); + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-control-command-policy { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 18px; + border: 1px solid rgba(255, 193, 92, 0.22); + border-radius: 22px; + background: rgba(255, 193, 92, 0.06); +} + +.device-control-command-policy > svg { + color: #ffc15c; +} + +.device-control-command-policy strong, +.device-control-command-policy p { + display: block; + margin: 0; +} + +.device-control-command-policy p { + margin-top: 5px; + color: rgba(255, 255, 255, 0.58); + font-size: 13px; + line-height: 1.5; +} + +.device-control-policy-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.device-control-policy-grid .nodedc-glass-surface { + display: grid; + gap: 8px; +} + +.device-control-policy-grid small { + color: rgba(255, 255, 255, 0.46); +} + +.device-control-policy-grid strong { + font-size: 14px; +} + +.device-control-form { + max-height: min(62vh, 680px); + overflow-y: auto; + padding-right: 3px; +} + +@media (max-width: 760px) { + .device-control-resource-grid, + .device-control-policy-grid { + grid-template-columns: 1fr; + } + + .device-control-toolbar { + align-items: stretch; + } + + .device-control-toolbar__actions, + .device-control-toolbar__actions > * { + width: 100%; + } + + .device-control-command-policy { + grid-template-columns: auto 1fr; + } + + .device-control-command-policy > .nodedc-status { + grid-column: 2; + justify-self: start; + } + + .device-control-row { + grid-template-columns: auto minmax(0, 1fr); + } + + .device-control-row__status { + grid-column: 2; + justify-content: flex-start; + } +} + html, body, #root { diff --git a/apps/device-manager/src/types.ts b/apps/device-manager/src/types.ts index b86ed62..c1559d1 100644 --- a/apps/device-manager/src/types.ts +++ b/apps/device-manager/src/types.ts @@ -94,10 +94,184 @@ export interface EnrollmentView { updatedAt: string | null; } +export interface AdapterPackageView { + adapterPackageRef: string; + packageKey: string; + displayName: string; + publisherRef: string; + lifecycleState: string; + createdAt: string | null; + updatedAt: string | null; +} + +export interface AdapterVersionView { + adapterVersionRef: string; + adapterPackageRef: string; + version: string; + runtimePackageRef: string; + contentDigest: string; + contractVersion: string; + capabilities: string[]; + lifecycleState: string; + createdAt: string | null; + updatedAt: string | null; +} + +export interface ModelProfileView { + modelProfileRef: string; + adapterVersionRef: string | null; + schemaVersion: string; + vendor: string; + model: string; + deviceType: string; + protocol: string; + schemaArtifactRef: string | null; + profileDigest: string | null; + capabilities: string[]; + lifecycleState: string; + createdAt: string | null; + updatedAt: string | null; +} + +export interface EdgeView { + edgeRef: string; + edgeKey: string; + displayName: string; + deploymentRef: string | null; + lifecycleState: string; + createdAt: string | null; + updatedAt: string | null; +} + +export interface RouteView { + routeRef: string; + routeKey: string; + displayName: string; + edgeRef: string; + edgeName: string; + modelProfileRef: string; + profileName: string; + listenerRef: string; + protocol: string; + direction: string; + lifecycleState: string; + sessionCount: number; + activeSessionCount: number; + createdAt: string | null; + updatedAt: string | null; +} + +export interface SessionView { + sessionRef: string; + routeRef: string; + routeName: string; + deviceRef: string | null; + deviceName: string | null; + protocol: string; + lifecycleState: string; + connectedAt: string | null; + lastSeenAt: string | null; + disconnectedAt: string | null; + closeReasonCode: string | null; + frameCount: number; + byteCount: number; +} + +export interface BindingView { + bindingRef: string; + bindingKey: string; + displayName: string; + source: { kind: string; ref: string; displayName: string }; + target: { kind: string; ref: string }; + capabilities: string[]; + lifecycleState: string; + sourceApprovedAt: string | null; + createdAt: string | null; + updatedAt: string | null; +} + +export interface ConfigurationRevisionView { + configurationRevisionRef: string; + deviceRef: string; + deviceName: string; + revisionNumber: number; + modelProfileRef: string; + schemaArtifactRef: string; + configurationDigest: string; + changeSummary: string | null; + createdAt: string | null; +} + +export interface ConfigurationStateView { + deviceRef: string; + deviceName: string; + desiredConfigurationRevisionRef: string | null; + appliedConfigurationRevisionRef: string | null; + appliedAt: string | null; + updatedAt: string | null; +} + +export interface CommandView { + commandRef: string; + deviceRef: string; + deviceName: string; + commandKey: string; + commandCatalogRef: string; + commandType: string; + riskClass: string; + lifecycleState: string; + plannedAt: string | null; + expiresAt: string | null; + confirmedAt: string | null; + dispatchedAt: string | null; + acknowledgedAt: string | null; + terminalAt: string | null; + terminalReasonCode: string | null; + createdAt: string | null; + updatedAt: string | null; +} + +export interface AuditEventView { + auditEventRef: string; + eventType: string; + actorRef: string; + deviceRef: string | null; + discoveryRef: string | null; + occurredAt: string | null; +} + +export interface ProjectGrantView { + grantRef: string; + principalKind: "user" | "group"; + principalRef: string; + projectRole: string; + capabilityAllow: string[]; + capabilityDeny: string[]; + lifecycleState: string; +} + export interface ProjectWorkspace { project: ProjectSummary; devices: DeviceView[]; collections: CollectionView[]; discoveries: DiscoveryView[]; enrollments: EnrollmentView[]; + adapterPackages: AdapterPackageView[]; + adapterVersions: AdapterVersionView[]; + modelProfiles: ModelProfileView[]; + edges: EdgeView[]; + routes: RouteView[]; + sessions: SessionView[]; + bindings: BindingView[]; + configurationRevisions: ConfigurationRevisionView[]; + configurationStates: ConfigurationStateView[]; + commands: CommandView[]; + auditEvents: AuditEventView[]; + grants: ProjectGrantView[]; + policies: { + commandTransport: "disabled" | "enabled"; + commandPlanningApi: "disabled" | "enabled"; + identifierProjection: string; + auditPayloadProjection: string; + }; }