feat(device-manager): add control and audit workspaces

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