feat: establish standalone Device Core repository

This commit is contained in:
DCCONSTRUCTIONS
2026-08-21 11:51:21 +03:00
commit e0bac205d0
244 changed files with 51962 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# NODE.DC Device Manager
Standalone Device Core application shell for Hub-authenticated device administration.
It is intentionally vendor-neutral: adapters and model profiles describe protocol-specific
behavior; projects, inventory, collections and access remain shared Device Core concepts.
## Runtime boundary
- The browser talks only to the Device Manager BFF under `/api/device-manager/*`.
- Launcher consumes the one-time handoff and periodically revalidates the process-local,
opaque Device Manager cookie.
- The BFF derives the Core actor from that trusted Hub identity. Browser-supplied role,
group or owner headers are ignored.
- 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 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
projects remain visible through explicit project grants, but company project creation stays
closed until Hub extends the handoff contract.
## Local source preview
The preview store starts empty and exists only to exercise the shell without a deployed Core.
All visible resources must still be created through the same command-shaped BFF endpoints.
It is forbidden when `NODE_ENV=production`.
```sh
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
npm run build --workspace @nodedc/device-manager
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
npm run serve --workspace @nodedc/device-manager
```
Production additionally requires:
- `NODEDC_LAUNCHER_BASE_URL`
- `NODEDC_LAUNCHER_INTERNAL_URL`
- `NODEDC_INTERNAL_ACCESS_TOKEN` or `NODEDC_PLATFORM_SERVICE_TOKEN`
- `NODEDC_DEVICE_CORE_INTERNAL_URL`
- `NODEDC_DEVICE_CORE_TOKEN_FILE`
The application source does not create a Hub service entry, DNS record, reverse proxy,
database or deployment artifact. Those remain explicit infrastructure phases.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#101114" />
<title>NODE.DC Device Core</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@nodedc/device-manager",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false",
"test": "node --test server/*.test.mjs",
"serve": "node server/device-manager-server.mjs"
},
"dependencies": {
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
}
}
@@ -0,0 +1 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55"><path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/><polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/></svg>

After

Width:  |  Height:  |  Size: 315 B

@@ -0,0 +1,919 @@
import { randomUUID } from "node:crypto";
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"],
["devices:update", "/internal/v1/management/devices:update"],
["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",
],
["commands:service-ping", "/internal/v1/commands:service-ping"],
]);
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
const endpoint = normalizeBaseUrl(baseUrl);
if (typeof token !== "string" || token.length < 32) {
throw serviceError("device_core_token_invalid", 503);
}
async function request(pathname, actor, init = {}) {
const response = await fetchImpl(new URL(pathname, endpoint), {
...init,
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...actorHeaders(actor),
...(init.headers ?? {}),
},
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => null);
if (!response.ok || body?.ok !== true) {
throw serviceError(
safeCoreError(body?.error),
response.status >= 400 && response.status < 600 ? response.status : 502,
);
}
return body;
}
return {
configured: true,
async listProjects(actor) {
return request("/internal/v1/query/projects", actor)
.then((body) => body.projects);
},
async getWorkspace(actor, projectRef) {
const projectId = entityId(projectRef, "project");
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
.then((body) => body.workspace);
},
async execute(command, actor, input, idempotencyKey) {
const pathname = commandRoutes.get(command);
if (!pathname) throw serviceError("device_manager_command_invalid", 404);
if (!/^[\x21-\x7e]{8,256}$/.test(idempotencyKey || "")) {
throw serviceError("device_idempotency_key_invalid", 400);
}
return request(pathname, actor, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(input),
}).then(({ replayed, result }) => ({ replayed, result }));
},
};
}
export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
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 enrollments = new Map();
const devices = new Map();
const sessions = new Map();
const bindings = new Map();
const grants = new Map();
const configurationRevisions = new Map();
const configurationStates = new Map();
const auditEvents = [];
const commands = new Map();
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()]
.filter((collection) => collection.projectRef === project.projectRef);
return {
...project,
counts: {
devices: projectValues(devices, project.projectRef).length,
collections: projectCollections.length,
discoveries: 0,
},
};
}
function workspace(projectRef) {
const project = projects.get(projectRef);
if (!project) throw serviceError("device_project_not_found", 404);
return {
project: projectSummary(project),
devices: projectValues(devices, projectRef)
.map(({ projectRef: _projectRef, ...device }) => device),
discoveries: [],
enrollments: projectValues(enrollments, projectRef),
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: projectValues(sessions, projectRef)
.map(({ projectRef: _projectRef, ...session }) => session),
bindings: projectValues(bindings, projectRef),
configurationRevisions: projectValues(configurationRevisions, projectRef),
configurationStates: projectValues(configurationStates, projectRef),
commands: projectValues(commands, projectRef),
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
grants: projectValues(grants, projectRef),
policies: {
commandTransport: fixture === "arusnavi-b2"
? "typed-service-ping-v1"
: "disabled",
commandPlanningApi: fixture === "arusnavi-b2" ? "enabled" : "disabled",
identifierProjection: fixture === "arusnavi-b2" ? "authorized-full" : "masked-only",
auditPayloadProjection: "metadata-only",
},
};
}
if (fixture === "arusnavi-b2") seedArusnaviB2Preview({
ownerScopes,
projects,
devices,
modelProfiles,
edges,
routes,
sessions,
configurationStates,
});
else if (fixture != null && fixture !== "") {
throw serviceError("device_manager_preview_fixture_invalid", 400);
}
return {
configured: true,
async listProjects() {
return [...projects.values()].map(projectSummary);
},
async getWorkspace(_actor, projectRef) {
return workspace(projectRef);
},
async execute(command, actor, input) {
if (command === "commands:service-ping") {
if (fixture !== "arusnavi-b2") {
throw serviceError("device_command_transport_disabled", 409);
}
const device = devices.get(input.deviceRef);
if (!device || device.projectRef !== input.projectRef) {
throw serviceError("device_command_route_unavailable", 409);
}
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
throw serviceError("device_service_ping_access_code_invalid", 400);
}
const commandRef = `command:${randomUUID()}`;
const at = now();
const view = {
commandRef,
projectRef: input.projectRef,
deviceRef: input.deviceRef,
deviceName: device.displayName,
commandKey: `preview-service-ping-${randomUUID()}`,
commandCatalogRef: "arusnavi.b2.internal.v1:service-ping",
commandType: "service.ping",
riskClass: "low",
lifecycleState: "queued",
plannedAt: at,
expiresAt: new Date(Date.now() + Number(input.expiresInSeconds) * 1000).toISOString(),
confirmedAt: null,
dispatchedAt: null,
acknowledgedAt: null,
terminalAt: null,
terminalReasonCode: null,
createdAt: at,
updatedAt: at,
};
commands.set(commandRef, view);
return { replayed: false, result: view };
}
if (command === "owner-scopes:ensure") {
const key = `${input.scopeKind}:${input.ownerRef}`;
const created = !ownerScopes.has(key);
const scope = {
ownerScopeRef: ownerScopes.get(key)?.ownerScopeRef || `owner-scope:${randomUUID()}`,
scopeKind: input.scopeKind,
ownerRef: input.ownerRef,
displayName: input.displayName,
lifecycleState: "active",
};
ownerScopes.set(key, scope);
return { replayed: false, result: { created, ownerScope: scope } };
}
if (command === "projects:ensure") {
const scope = ownerScopes.get(`${input.scopeKind}:${input.ownerRef}`);
if (!scope) throw serviceError("device_owner_scope_not_found", 404);
const existing = [...projects.values()].find((project) =>
project.ownerScope.ownerRef === input.ownerRef
&& project.projectKey === input.projectKey
);
const projectRef = existing?.projectRef || `project:${randomUUID()}`;
const project = {
projectRef,
projectKey: input.projectKey,
name: input.name,
description: input.description ?? null,
lifecycleState: "active",
ownerScope: scope,
access: { projectRole: "owner", capabilities: ownerCapabilities },
counts: { devices: 0, collections: 0, discoveries: 0 },
createdAt: existing?.createdAt || new Date().toISOString(),
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") {
const projectRef = input.projectRef;
if (!projects.has(projectRef)) throw serviceError("device_project_not_found", 404);
const existing = [...collections.values()].find((collection) =>
collection.projectRef === projectRef
&& collection.collectionKey === input.collectionKey
);
const collectionRef = existing?.collectionRef || `collection:${randomUUID()}`;
const collection = {
collectionRef,
projectRef,
collectionKey: input.collectionKey,
name: input.name,
description: input.description ?? null,
lifecycleState: "active",
memberCount: existing?.memberCount || 0,
createdAt: existing?.createdAt || new Date().toISOString(),
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()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "active",
previewTransitions.adapterPackage,
"device_adapter_package_transition_invalid",
);
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);
const adapterPackage = adapterPackages.get(input.adapterPackageRef);
if (!adapterPackage) {
throw serviceError("device_adapter_package_not_found", 404);
}
if (adapterPackage.lifecycleState !== "active") {
throw serviceError("device_adapter_package_inactive", 409);
}
const existing = [...adapterVersions.values()].find((entry) =>
entry.adapterPackageRef === input.adapterPackageRef
&& entry.version === input.version
);
const adapterVersionRef = existing?.adapterVersionRef
|| `adapter-version:${randomUUID()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "draft",
previewTransitions.catalogVersion,
"device_adapter_version_transition_invalid",
);
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);
const adapterVersion = adapterVersions.get(input.adapterVersionRef);
if (!adapterVersion) {
throw serviceError("device_adapter_version_not_found", 404);
}
const existing = modelProfiles.get(input.profileRef);
const lifecycleState = input.lifecycleState ?? "draft";
if (lifecycleState === "active" && adapterVersion.lifecycleState !== "active") {
throw serviceError("device_model_profile_adapter_not_active", 409);
}
assertPreviewTransition(
existing?.lifecycleState,
lifecycleState,
previewTransitions.catalogVersion,
"device_model_profile_transition_invalid",
);
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,
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()}`;
assertPreviewTransition(
existing?.lifecycleState,
input.lifecycleState ?? "provisioning",
previewTransitions.edge,
"device_edge_transition_invalid",
);
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 lifecycleState = input.lifecycleState ?? "draft";
if (
lifecycleState === "active"
&& (edge.lifecycleState !== "active" || profile.lifecycleState !== "active")
) {
throw serviceError("device_route_dependency_not_active", 409);
}
assertPreviewTransition(
existing?.lifecycleState,
lifecycleState,
previewTransitions.route,
"device_route_transition_invalid",
);
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,
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") {
if (!projects.has(input.projectRef)) {
throw serviceError("device_project_not_found", 404);
}
const route = routes.get(input.routeRef);
if (!route || route.projectRef !== input.projectRef) {
throw serviceError("device_route_not_found", 404);
}
if (route.lifecycleState !== "active") {
throw serviceError("device_enrollment_route_inactive", 409);
}
if (route.modelProfileRef !== input.modelProfileRef) {
throw serviceError("device_enrollment_profile_mismatch", 409);
}
if (
input.identifier?.kind !== "imei"
|| typeof input.identifier.value !== "string"
|| !/^\d{15}$/.test(input.identifier.value)
) {
throw serviceError("restricted_identifier_imei_invalid", 400);
}
const existing = projectValues(enrollments, input.projectRef).find(
(entry) => entry.enrollmentKey === input.enrollmentKey,
);
const enrollmentIntentRef = existing?.enrollmentIntentRef
|| `enrollment-intent:${randomUUID()}`;
const enrollment = {
enrollmentIntentRef,
projectRef: input.projectRef,
enrollmentKey: input.enrollmentKey,
displayName: input.displayName,
routeRef: input.routeRef,
modelProfileRef: input.modelProfileRef,
expectedIdentifier: {
kind: "imei",
masked: `***********${input.identifier.value.slice(-4)}`,
},
lifecycleState: "pending",
observedDiscoveryRef: null,
claimedDeviceRef: null,
expiresAt: input.expiresAt ?? null,
createdAt: existing?.createdAt || now(),
updatedAt: now(),
};
enrollments.set(enrollmentIntentRef, enrollment);
audit(actor, input.projectRef, createdEvent(existing, "enrollment_intent"));
const { expectedIdentifier, ...safeEnrollment } = enrollment;
return {
replayed: false,
result: {
created: !existing,
enrollmentIntent: {
...safeEnrollment,
identifier: expectedIdentifier,
},
},
};
}
if (command === "devices:claim") {
throw serviceError("device_discovery_not_found", 404);
}
if (command === "devices:update") {
const device = devices.get(input.deviceRef);
if (!device || device.projectRef !== input.projectRef) {
throw serviceError("device_not_found", 404);
}
if (typeof input.displayName !== "string" || !input.displayName.trim()) {
throw serviceError("device_display_name_invalid", 400);
}
const updated = {
...device,
displayName: input.displayName.trim(),
integrationDeviceId: typeof input.integrationDeviceId === "string"
? input.integrationDeviceId.trim() || null
: null,
updatedAt: now(),
};
devices.set(input.deviceRef, updated);
audit(actor, input.projectRef, "device.updated");
return { replayed: false, result: { updated: true, device: updated } };
}
throw serviceError("device_manager_command_invalid", 404);
},
snapshot() {
return {
ownerScopes,
projects,
collections,
adapterPackages,
adapterVersions,
modelProfiles,
edges,
routes,
enrollments,
devices,
sessions,
bindings,
grants,
configurationRevisions,
configurationStates,
commands,
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);
}
}
function seedArusnaviB2Preview({
ownerScopes,
projects,
devices,
modelProfiles,
edges,
routes,
sessions,
configurationStates,
}) {
const timestamp = new Date().toISOString();
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
const projectRef = "project:ad7b357c-c7ac-4bf8-a638-c7f956e9aa71";
const deviceRef = "device:b6a55921-7888-44b5-a93e-241aa2fdd3d7";
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
const scope = {
ownerScopeRef,
scopeKind: "personal",
ownerRef: "user:local-device-admin",
displayName: "Local Device Admin",
lifecycleState: "active",
};
ownerScopes.set("personal:user:local-device-admin", scope);
projects.set(projectRef, {
projectRef,
projectKey: "arusnavi-b2-preview",
name: "ARUSNAVI B2 preview",
description: "Локальная визуальная фикстура пилотного ARUSNAVI B2",
lifecycleState: "active",
ownerScope: scope,
access: { projectRole: "owner", capabilities: ownerCapabilities },
counts: { devices: 1, collections: 0, discoveries: 0 },
createdAt: timestamp,
updatedAt: timestamp,
});
modelProfiles.set("arusnavi.b2.internal.v1", {
modelProfileRef: "arusnavi.b2.internal.v1",
adapterVersionRef: null,
schemaVersion: "1.0.0",
vendor: "ARUSNAVI",
model: "B2",
deviceType: "tracker",
protocol: "INTERNAL",
schemaArtifactRef: "schema:arusnavi.b2.internal.v1",
profileDigest: null,
capabilities: ["telemetry", "configuration", "commands"],
lifecycleState: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
edges.set(edgeRef, {
edgeRef,
edgeKey: "preview-edge",
displayName: "Preview VPS edge",
deploymentRef: "deployment:preview",
lifecycleState: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
routes.set(routeRef, {
routeRef,
projectRef,
routeKey: "preview-b2-route",
displayName: "B2 direct preview",
edgeRef,
edgeName: "Preview VPS edge",
modelProfileRef: "arusnavi.b2.internal.v1",
profileName: "ARUSNAVI B2",
listenerRef: "listener:preview",
protocol: "INTERNAL",
direction: "bidirectional",
lifecycleState: "active",
sessionCount: 1,
activeSessionCount: 1,
createdAt: timestamp,
updatedAt: timestamp,
});
devices.set(deviceRef, {
projectRef,
deviceRef,
deviceKey: "pilot-b2-preview",
displayName: "Пилотный B2",
integrationDeviceId: "8028",
modelProfileRef: "arusnavi.b2.internal.v1",
lifecycleState: "active",
identifier: {
kind: "imei",
masked: "***********1088",
value: "863151070211088",
},
session: { state: "online", lastSeenAt: timestamp },
reported: {
observedAt: timestamp,
identity: {
imei: "863151070211088",
iccid1: "****************1111",
iccid2: "****************2222",
},
firmware: { currentVersion: "0.02", appliedAt: timestamp, availableVersion: "0.05" },
configuration: {
monitoring: {
servers: [
{ host: "legacy.example.invalid", port: 20623, protocol: "INTERNAL", identity: "0" },
{ host: "direct.example.invalid", port: 9921, protocol: "INTERNAL", identity: "0" },
],
},
transmission: { navigation: { position: true, motion: true, hdop: false } },
trajectory: {
normal: { courseDeltaDegrees: 15, speedDeltaKph: 10, distanceMeters: 15, parkingIntervalSeconds: 15 },
roaming: { courseDeltaDegrees: 20, speedDeltaKph: 50, distanceMeters: 1000, parkingIntervalSeconds: 300 },
},
navigation: {
sources: { satellite: true, wifi: false, lbs: false, tag: false },
constellations: { gps: true, glonass: true, galileo: false, beidou: false },
filter: { minimumSatellites: 4, maximumHdopTimesTen: 30 },
},
},
telemetry: {
navigation: {
latitude: "55.7500",
longitude: "37.6200",
speedKph: 18,
altitudeMeters: 156,
satellites: 12,
courseDegrees: 84,
hdop: 1.2,
},
gsm: { signal: 79, operator: "preview", lac: "masked", cid: "masked" },
system: { externalVoltageMv: 13240, internalVoltageMv: 4120, status: "Норма" },
},
},
createdAt: timestamp,
updatedAt: timestamp,
});
sessions.set(sessionRef, {
sessionRef,
projectRef,
routeRef,
routeName: "B2 direct preview",
deviceRef,
deviceName: "Пилотный B2",
protocol: "INTERNAL",
lifecycleState: "online",
connectedAt: timestamp,
lastSeenAt: timestamp,
disconnectedAt: null,
closeReasonCode: null,
frameCount: 1842,
byteCount: 734208,
});
configurationStates.set(deviceRef, {
projectRef,
deviceRef,
deviceName: "Пилотный B2",
desiredConfigurationRevisionRef: null,
appliedConfigurationRevisionRef: null,
appliedAt: null,
updatedAt: timestamp,
});
}
const previewTransitions = Object.freeze({
adapterPackage: Object.freeze({
active: Object.freeze(["active", "retired"]),
retired: Object.freeze(["retired"]),
}),
catalogVersion: Object.freeze({
draft: Object.freeze(["draft", "active", "retired"]),
active: Object.freeze(["active", "retired"]),
retired: Object.freeze(["retired"]),
}),
edge: Object.freeze({
provisioning: Object.freeze(["provisioning", "active", "retired"]),
active: Object.freeze(["active", "suspended", "retired"]),
suspended: Object.freeze(["suspended", "active", "retired"]),
retired: Object.freeze(["retired"]),
}),
route: Object.freeze({
draft: Object.freeze(["draft", "active", "retired"]),
active: Object.freeze(["active", "suspended", "retired"]),
suspended: Object.freeze(["suspended", "active", "retired"]),
retired: Object.freeze(["retired"]),
}),
});
function assertPreviewTransition(previous, next, transitions, code) {
if (!previous) return;
if (!transitions[previous]?.includes(next)) {
throw serviceError(code, 409);
}
}
const ownerCapabilities = Object.freeze([
"project.read",
"project.manage",
"access.manage",
"inventory.read",
"device.enroll",
"device.claim",
"device.transfer",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"configuration.read",
"configuration.manage",
"command.plan",
"command.confirm",
"command.dispatch",
"credential.manage",
"audit.read",
]);
function actorHeaders(actor) {
if (!actor || typeof actor !== "object") throw serviceError("device_actor_required", 401);
return {
"X-NODEDC-User-Ref": actor.userRef,
"X-NODEDC-Hub-Role": actor.hubRole,
"X-NODEDC-Group-Refs": (actor.groupRefs ?? []).join(","),
"X-NODEDC-Owner-Scopes": (actor.ownerScopes ?? [])
.map((scope) => `${scope.scopeKind}=${scope.ownerRef}`)
.join(","),
};
}
function entityId(value, prefix) {
const match = String(value || "").match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw serviceError(`device_${prefix}_ref_invalid`, 400);
return match[1].toLowerCase();
}
function normalizeBaseUrl(value) {
if (typeof value !== "string" || value.trim() === "") {
throw serviceError("device_core_url_required", 503);
}
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw serviceError("device_core_url_invalid", 503);
}
url.pathname = url.pathname.replace(/\/$/, "") || "/";
return url;
}
function safeCoreError(value) {
return typeof value === "string" && /^device_[a-z0-9._:-]{2,120}$/.test(value)
? value
: "device_core_unavailable";
}
function serviceError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createDeviceCoreClient,
createLocalPreviewDeviceCore,
} from "./device-core-client.mjs";
const token = "device-core-test-token-that-is-never-exposed";
const actor = Object.freeze({
userRef: "user:device-admin",
hubRole: "admin",
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 = [];
const client = createDeviceCoreClient({
baseUrl: "http://device-control-core:3210",
token,
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return jsonResponse(200, { ok: true, projects: [] });
},
});
assert.deepEqual(await client.listProjects(actor), []);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.headers.Authorization, `Bearer ${token}`);
assert.equal(calls[0].init.headers["X-NODEDC-User-Ref"], actor.userRef);
assert.equal(calls[0].init.headers["X-NODEDC-Hub-Role"], "admin");
assert.equal(calls[0].init.headers["X-NODEDC-Group-Refs"], "group:device-engineers");
assert.equal(
calls[0].init.headers["X-NODEDC-Owner-Scopes"],
"personal=user:device-admin",
);
assert.equal(JSON.stringify(await client.listProjects(actor)).includes(token), false);
});
test("Device Core client accepts only canonical commands and entity refs", async () => {
const client = createDeviceCoreClient({
baseUrl: "http://127.0.0.1:3210",
token,
fetchImpl: async () => jsonResponse(200, { ok: true, replayed: false, result: {} }),
});
await assert.rejects(
client.execute("raw:proxy", actor, {}, "device-manager-12345678"),
/device_manager_command_invalid/,
);
await assert.rejects(
client.getWorkspace(actor, "project:not-a-uuid"),
/device_project_ref_invalid/,
);
await assert.rejects(
client.execute("projects:ensure", actor, {}, "short"),
/device_idempotency_key_invalid/,
);
});
test("local preview is empty and creates resources only through canonical commands", async () => {
const client = createLocalPreviewDeviceCore();
assert.deepEqual(await client.listProjects(actor), []);
const owner = await client.execute("owner-scopes:ensure", actor, {
scopeKind: "personal",
ownerRef: actor.userRef,
displayName: "Device Admin",
});
assert.equal(owner.result.created, true);
const created = await client.execute("projects:ensure", actor, {
scopeKind: "personal",
ownerRef: actor.userRef,
projectKey: "sandbox",
name: "Device sandbox",
description: null,
});
assert.equal(created.result.created, true);
const projectRef = created.result.project.projectRef;
await client.execute("collections:ensure", actor, {
projectRef,
collectionKey: "field-devices",
name: "Field devices",
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",
});
const route = 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",
});
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: "active",
});
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: "active",
});
await client.execute("edges:ensure", platformActor, {
edgeKey: "preview-edge",
displayName: "Preview Edge",
deploymentRef: "deployment:preview-edge",
lifecycleState: "active",
});
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: "active",
});
const enrollment = await client.execute("enrollment-intents:ensure", actor, {
projectRef,
enrollmentKey: "preview-device",
routeRef: route.result.route.routeRef,
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
displayName: "Preview device",
identifier: { kind: "imei", value: "123456789012345" },
expiresAt: null,
});
assert.equal(enrollment.result.enrollmentIntent.identifier.masked, "***********2345");
assert.equal(JSON.stringify(enrollment).includes("123456789012345"), false);
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.routes[0].lifecycleState, "active");
assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********2345");
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, []);
});
test("explicit B2 preview fixture is isolated from the empty canonical preview", async () => {
const client = createLocalPreviewDeviceCore({ fixture: "arusnavi-b2" });
const projects = await client.listProjects(actor);
assert.equal(projects.length, 1);
assert.equal(projects[0].counts.devices, 1);
const workspace = await client.getWorkspace(actor, projects[0].projectRef);
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
assert.equal(workspace.devices[0].identifier.masked, "***********1088");
assert.equal(workspace.devices[0].identifier.value, "863151070211088");
assert.equal(workspace.devices[0].integrationDeviceId, "8028");
assert.equal(workspace.sessions[0].lifecycleState, "online");
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
assert.equal(workspace.policies.identifierProjection, "authorized-full");
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
assert.throws(
() => createLocalPreviewDeviceCore({ fixture: "unknown" }),
/device_manager_preview_fixture_invalid/,
);
});
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
@@ -0,0 +1,465 @@
import { randomBytes } from "node:crypto";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const DEFAULT_VALIDATION_TTL_MS = 20_000;
const DEFAULT_VALIDATION_GRACE_MS = 30_000;
export function createDeviceManagerAuth({
env = process.env,
fetchImpl = fetch,
now = Date.now,
internalToken: providedInternalToken,
} = {}) {
const authRequired = booleanValue(
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED,
env.NODE_ENV === "production",
);
const serviceSlug = textValue(env.NODEDC_DEVICE_MANAGER_SERVICE_SLUG, "device-core");
const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
const internalToken = textValue(
providedInternalToken
|| env.NODEDC_INTERNAL_ACCESS_TOKEN
|| env.NODEDC_PLATFORM_SERVICE_TOKEN,
"",
);
const sessionCookie = textValue(
env.NODEDC_DEVICE_MANAGER_SESSION_COOKIE,
"nodedc_device_manager_session",
);
const sessionTtlMs = boundedInteger(
env.NODEDC_DEVICE_MANAGER_SESSION_TTL_MS,
DEFAULT_SESSION_TTL_MS,
60_000,
24 * 60 * 60 * 1000,
);
const validationTtlMs = boundedInteger(
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_TTL_MS,
DEFAULT_VALIDATION_TTL_MS,
15_000,
30_000,
);
const validationGraceMs = boundedInteger(
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_GRACE_MS,
DEFAULT_VALIDATION_GRACE_MS,
0,
60_000,
);
const secureCookie = booleanValue(
env.NODEDC_DEVICE_MANAGER_COOKIE_SECURE,
authRequired,
);
const sessions = new Map();
function buildCookie(value, maxAgeSeconds) {
return [
`${sessionCookie}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
...(secureCookie ? ["Secure"] : []),
].join("; ");
}
function createSession(response, handoff) {
pruneSessions();
const id = randomBytes(32).toString("base64url");
const createdAt = now();
sessions.set(id, {
id,
user: handoff.user,
access: handoff.access,
launcherSessionId: handoff.launcherSessionId,
expiresAt: createdAt + sessionTtlMs,
validatedAt: createdAt,
validationInFlight: null,
});
appendCookie(response, buildCookie(id, sessionTtlMs / 1000));
}
function currentSession(request) {
const id = parseCookies(request.headers.cookie)[sessionCookie];
const session = id ? sessions.get(id) : null;
if (!session || session.expiresAt <= now()) {
if (id) sessions.delete(id);
return null;
}
return session;
}
async function launcherRequest(pathname, payload) {
if (!internalToken) throw serviceError("device_manager_auth_not_configured", 503);
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${internalToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8_000),
});
const body = await response.json().catch(() => null);
return { response, body };
}
async function handleHandoff(request, response, url) {
const nextPath = safeReturnTo(
url.searchParams.get("next_path") || url.searchParams.get("returnTo"),
);
if (!authRequired) return redirect(response, nextPath);
const token = String(url.searchParams.get("token") || "");
if (!token) return sendText(response, 400, "Missing Launcher handoff token.");
try {
const result = await launcherRequest("/api/internal/handoff/consume", {
token,
serviceSlug,
});
if (!result.response.ok || result.body?.ok !== true || !result.body?.user) {
return sendText(response, 401, "Launcher handoff rejected.");
}
createSession(response, {
user: result.body.user,
access: result.body.access,
launcherSessionId: result.body.launcherSessionId ?? null,
});
return redirect(response, nextPath);
} catch {
return sendText(response, 401, "Launcher handoff failed.");
}
}
async function validatedSession(request, response) {
if (!authRequired) {
return attachSession(request, {
user: {
id: "local-device-admin",
email: "local-device-admin@nodedc.local",
name: "Local Device Admin",
avatarUrl: null,
groups: ["nodedc:superadmin"],
},
});
}
const session = currentSession(request);
if (!session) {
clearCookie(response);
return null;
}
if (now() - session.validatedAt <= validationTtlMs) {
return attachSession(request, session);
}
if (!session.validationInFlight) {
session.validationInFlight = launcherRequest("/api/internal/session/validate", {
serviceSlug,
launcherSessionId: session.launcherSessionId,
}).finally(() => {
session.validationInFlight = null;
});
}
try {
const { response: upstream, body } = await session.validationInFlight;
if (upstream.ok && body?.ok === true && body.active === true) {
session.user = body.user || session.user;
session.access = body.access;
session.validatedAt = now();
return attachSession(request, session);
}
if (upstream.ok && body?.ok === true && body.active === false) {
sessions.delete(session.id);
clearCookie(response);
return null;
}
} catch {
// Read-only grace is resolved below; mutations always fail closed.
}
const readOnly = request.method === "GET" || request.method === "HEAD";
if (readOnly && now() - session.validatedAt <= validationTtlMs + validationGraceMs) {
return attachSession(request, session);
}
request.nodedcDeviceManagerAuthUnavailable = true;
return null;
}
async function authorize(request, response, url) {
if (
url.pathname === "/healthz"
|| url.pathname === "/auth/nodedc/handoff"
|| url.pathname === "/auth/logout"
) return false;
const session = await validatedSession(request, response);
if (!session) {
if (request.nodedcDeviceManagerAuthUnavailable) {
sendJson(response, 503, { ok: false, error: "device_manager_auth_unavailable" });
return true;
}
const loginUrl = new URL("/auth/login", launcherBaseUrl);
const launch = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
launch.searchParams.set("returnTo", safeReturnTo(`${url.pathname}${url.search}`));
loginUrl.searchParams.set("returnTo", `${launch.pathname}${launch.search}`);
if (isHtmlRequest(request, url)) {
redirect(response, loginUrl.toString());
return true;
}
sendJson(response, 401, {
ok: false,
error: "device_manager_auth_required",
loginUrl: loginUrl.toString(),
});
return true;
}
const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired });
if (!access.allowed) {
sendJson(response, 403, {
ok: false,
error: access.blocked
? "device_manager_access_blocked"
: "device_manager_access_denied",
});
return true;
}
request.nodedcDeviceManagerAccess = access;
return false;
}
function handleLogout(request, response) {
const id = parseCookies(request.headers.cookie)[sessionCookie];
if (id) sessions.delete(id);
clearCookie(response);
redirect(response, "/");
}
function currentContext(request) {
const user = request.nodedcDeviceManagerSession?.user;
const trustedAccess = request.nodedcDeviceManagerSession?.access;
const access = request.nodedcDeviceManagerAccess
?? resolveAccess(user, trustedAccess, { allowLegacy: !authRequired });
if (!user || !access.allowed) return null;
const id = cleanOpaque(user.id || user.subject || user.sub);
if (!id) return null;
const email = String(user.email || "").trim().slice(0, 240);
const displayName = String(user.name || user.displayName || email || "NODE.DC")
.trim()
.slice(0, 240);
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
const userRef = `user:${id}`;
return {
user: {
id,
email,
displayName,
avatarUrl: /^https:\/\//i.test(avatar) || avatar.startsWith("/") ? avatar : null,
initials: initials(displayName),
},
actor: {
userRef,
hubRole: access.hubRole,
groupRefs: access.groups.map((group) => `group:${group}`),
ownerScopes: access.ownerScopes,
},
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
};
}
function clearCookie(response) {
appendCookie(response, buildCookie("", 0));
}
function pruneSessions() {
const current = now();
for (const [id, session] of sessions) {
if (session.expiresAt <= current) sessions.delete(id);
}
}
return {
authRequired,
internalAccessConfigured: Boolean(internalToken),
serviceSlug,
authorize,
currentContext,
handleHandoff,
handleLogout,
};
}
function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) {
if (!user || typeof user !== "object") {
return deniedAccess();
}
const groups = normalizedGroups(user);
if (groups.includes("nodedc:device-core:blocked")) {
return { ...deniedAccess(groups), blocked: true };
}
const id = cleanOpaque(user.id || user.subject || user.sub);
if (!id) return deniedAccess(groups);
const claims = normalizeTrustedAccess(trustedAccess, id);
if (claims) return { ...claims, blocked: false, groups };
if (!allowLegacy) return deniedAccess(groups);
const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
? "owner"
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
? "admin"
: groups.includes("nodedc:device-core:viewer")
? "viewer"
: "member";
const ownerScopes = ["admin", "owner"].includes(hubRole)
? [{
scopeKind: "personal",
ownerRef: `user:${id}`,
displayName: String(user.name || user.displayName || user.email || id).trim().slice(0, 240),
}]
: [];
return { allowed: true, blocked: false, hubRole, groups, ownerScopes };
}
function normalizeTrustedAccess(input, userId) {
if (!input || typeof input !== "object" || input.allowed !== true) return null;
const hubRole = ["viewer", "member", "admin", "owner"].includes(input.hubRole)
? input.hubRole
: null;
if (!hubRole || !Array.isArray(input.ownerScopes)) return null;
const ownerScopes = [];
for (const item of input.ownerScopes) {
if (!item || typeof item !== "object") return null;
const scopeKind = item.scopeKind === "company" || item.scopeKind === "personal"
? item.scopeKind
: null;
const ownerRef = cleanOpaque(item.ownerRef);
const validOwner = scopeKind === "personal"
? ownerRef === `user:${userId}`
: ownerRef?.startsWith("client:") && ownerRef.length > "client:".length;
if (!scopeKind || !validOwner) return null;
ownerScopes.push({
scopeKind,
ownerRef,
displayName: String(item.displayName || ownerRef).trim().slice(0, 240),
});
}
return {
allowed: true,
hubRole,
ownerScopes: [...new Map(ownerScopes.map((scope) => [
`${scope.scopeKind}\0${scope.ownerRef}`,
scope,
])).values()],
};
}
function deniedAccess(groups = []) {
return {
allowed: false,
blocked: false,
hubRole: "viewer",
groups,
ownerScopes: [],
};
}
function normalizedGroups(user) {
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
const groups = [];
for (const value of values) {
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
for (const item of items) {
const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug;
const normalized = String(raw || "").trim().toLowerCase();
if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized);
}
}
return [...new Set(groups)].sort();
}
function attachSession(request, session) {
request.nodedcDeviceManagerSession = session;
return session;
}
function cleanOpaque(value) {
const normalized = String(value || "").trim();
return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized)
? normalized
: null;
}
function parseCookies(header = "") {
const values = {};
for (const part of String(header).split(";")) {
const index = part.indexOf("=");
if (index < 1) continue;
const key = part.slice(0, index).trim();
try {
values[key] = decodeURIComponent(part.slice(index + 1).trim());
} catch {
values[key] = part.slice(index + 1).trim();
}
}
return values;
}
function appendCookie(response, value) {
const current = response.getHeader("Set-Cookie");
response.setHeader("Set-Cookie", current ? [current, value].flat() : value);
}
function safeReturnTo(value) {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
? value
: "/";
}
function isHtmlRequest(request, url) {
return request.method === "GET"
&& !url.pathname.startsWith("/api/")
&& (url.pathname === "/" || String(request.headers.accept || "").includes("text/html"));
}
function redirect(response, location) {
response.statusCode = 302;
response.setHeader("Location", location);
response.setHeader("Cache-Control", "no-store");
response.end();
}
function sendJson(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify(body));
}
function sendText(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(body);
}
function initials(value) {
return value.split(/\s+/).filter(Boolean).slice(0, 2)
.map((part) => part[0]).join("").toUpperCase() || "DC";
}
function booleanValue(value, fallback) {
if (value == null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
function boundedInteger(value, fallback, min, max) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}
function textValue(value, fallback) {
return String(value || fallback).trim();
}
function baseUrl(value, fallback) {
return textValue(value, fallback).replace(/\/$/, "");
}
function serviceError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
const internalToken = "launcher-internal-token-must-stay-server-side";
const launcherSessionId = "launcher-session-id-must-stay-server-side";
test("Launcher handoff becomes an opaque Device Manager session and trusted actor", async () => {
const calls = [];
const auth = createDeviceManagerAuth({
env: productionEnv(),
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init });
return jsonResponse(200, {
ok: true,
launcherSessionId,
access: {
allowed: true,
hubRole: "owner",
ownerScopes: [
{
scopeKind: "company",
ownerRef: "client:client_dctouch",
displayName: "DC Touch",
},
{
scopeKind: "personal",
ownerRef: "user:user_root",
displayName: "DC SUDO",
},
],
},
user: {
id: "user_root",
email: "root@example.test",
name: "DC SUDO",
groups: ["nodedc:superadmin", "nodedc:device-core:admin"],
},
});
},
});
const response = mockResponse();
await auth.handleHandoff(
{ method: "GET", headers: {} },
response,
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret&next_path=%2F"),
);
assert.equal(response.statusCode, 302);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.headers.Authorization, `Bearer ${internalToken}`);
assert.deepEqual(JSON.parse(calls[0].init.body), {
token: "handoff-secret",
serviceSlug: "device-core",
});
const cookie = String(response.getHeader("set-cookie")).split(";", 1)[0];
assert.match(cookie, /^nodedc_device_manager_session=[A-Za-z0-9_-]{40,}$/);
assert.equal(cookie.includes("user_root"), false);
assert.equal(cookie.includes(launcherSessionId), false);
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
const authorized = await auth.authorize(
request,
mockResponse(),
new URL("https://device.example.test/api/device-manager/session"),
);
assert.equal(authorized, false);
const context = auth.currentContext(request);
assert.equal(context.actor.userRef, "user:user_root");
assert.equal(context.actor.hubRole, "owner");
assert.deepEqual(context.actor.ownerScopes, [
{
scopeKind: "company",
ownerRef: "client:client_dctouch",
displayName: "DC Touch",
},
{
scopeKind: "personal",
ownerRef: "user:user_root",
displayName: "DC SUDO",
},
]);
assert.deepEqual(context.actor.groupRefs, [
"group:nodedc:device-core:admin",
"group:nodedc:superadmin",
]);
assert.equal(JSON.stringify(context).includes(launcherSessionId), false);
assert.equal(JSON.stringify(context).includes(internalToken), false);
});
test("invalid identity and explicit Device Core block never produce an actor", async () => {
for (const user of [
{ id: "?", groups: ["nodedc:device-core:admin"] },
{ id: "valid-user", groups: ["nodedc:superadmin", "nodedc:device-core:blocked"] },
]) {
const auth = createDeviceManagerAuth({
env: productionEnv(),
fetchImpl: async () => jsonResponse(200, {
ok: true,
launcherSessionId,
access: {
allowed: true,
hubRole: "admin",
ownerScopes: [],
},
user,
}),
});
const handoff = mockResponse();
await auth.handleHandoff(
{ method: "GET", headers: {} },
handoff,
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
);
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
const response = mockResponse();
assert.equal(await auth.authorize(
request,
response,
new URL("https://device.example.test/api/device-manager/session"),
), true);
assert.equal(response.statusCode, 403);
}
});
test("production auth fails closed when Launcher omits trusted Device Core access", async () => {
const auth = createDeviceManagerAuth({
env: productionEnv(),
fetchImpl: async () => jsonResponse(200, {
ok: true,
launcherSessionId,
user: {
id: "user_root",
email: "root@example.test",
name: "DC SUDO",
groups: ["nodedc:superadmin"],
},
}),
});
const handoff = mockResponse();
await auth.handleHandoff(
{ method: "GET", headers: {} },
handoff,
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
);
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
const response = mockResponse();
assert.equal(await auth.authorize(
{ method: "GET", headers: { cookie, accept: "application/json" } },
response,
new URL("https://device.example.test/api/device-manager/session"),
), true);
assert.equal(response.statusCode, 403);
assert.equal(JSON.parse(response.body).error, "device_manager_access_denied");
});
test("an injected file-backed token takes precedence over broad platform env tokens", async () => {
const calls = [];
const auth = createDeviceManagerAuth({
env: { ...productionEnv(), NODEDC_INTERNAL_ACCESS_TOKEN: "broad-platform-token" },
internalToken: "scoped-file-token",
fetchImpl: async (url, init) => {
calls.push({ url, init });
return jsonResponse(200, {
ok: true,
launcherSessionId,
access: { allowed: true, hubRole: "member", ownerScopes: [] },
user: {
id: "device-member",
email: "member@example.test",
name: "Device Member",
groups: ["nodedc:device-core:access"],
},
});
},
});
await auth.handleHandoff(
{ method: "GET", headers: {} },
mockResponse(),
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
);
assert.equal(calls[0].init.headers.Authorization, "Bearer scoped-file-token");
});
function productionEnv() {
return {
NODE_ENV: "production",
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "false",
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
NODEDC_INTERNAL_ACCESS_TOKEN: internalToken,
};
}
function mockResponse() {
const headers = new Map();
return {
statusCode: 200,
body: "",
setHeader(name, value) { headers.set(String(name).toLowerCase(), value); },
getHeader(name) { return headers.get(String(name).toLowerCase()); },
end(body = "") { this.body = String(body); },
};
}
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
@@ -0,0 +1,261 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { basename, dirname, extname, join, resolve, sep } from "node:path";
const DEFAULT_ACCENT = "#b9ff4a";
export function createDeviceManagerPresentationStore({
layoutPath,
uploadRoot,
} = {}) {
const resolvedLayoutPath = resolve(layoutPath || "runtime-data/device-manager-presentation.json");
const resolvedUploadRoot = resolve(uploadRoot || "runtime-data/device-manager-media");
return {
mediaRoot: resolvedUploadRoot,
async read() {
const raw = await readFile(resolvedLayoutPath, "utf8").catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!raw) return defaultPresentation();
try {
return normalizePresentation(JSON.parse(raw));
} catch {
throw serviceError("device_manager_presentation_invalid", 500);
}
},
async write(next) {
const normalized = normalizePresentation(next);
await mkdir(dirname(resolvedLayoutPath), { recursive: true });
const temporaryPath = `${resolvedLayoutPath}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o640 });
await rename(temporaryPath, resolvedLayoutPath);
return normalized;
},
async saveMedia({ bytes, contentType, originalName, kind }) {
const extension = allowedExtension(contentType, originalName, kind);
await mkdir(resolvedUploadRoot, { recursive: true });
const fileName = `${kind}-${randomUUID()}${extension}`;
await writeFile(join(resolvedUploadRoot, fileName), bytes, { flag: "wx", mode: 0o640 });
return {
fileName: String(originalName || fileName).slice(0, 180),
fileSrc: `/device-manager-media/${fileName}`,
};
},
resolveMedia(pathname) {
const encodedName = pathname.match(/^\/device-manager-media\/([^/]+)$/)?.[1];
if (!encodedName) return null;
const name = basename(decodeURIComponent(encodedName));
if (!/^[a-z]+-[0-9a-f-]+\.(?:png|jpe?g|webp|gif|avif|mp4|webm|mov)$/i.test(name)) return null;
const candidate = resolve(resolvedUploadRoot, name);
return candidate.startsWith(`${resolvedUploadRoot}${sep}`) ? candidate : null;
},
};
}
export function defaultPresentation() {
return {
environment: {
theme: "dark",
accentHex: DEFAULT_ACCENT,
overview: defaultOverview(),
},
projects: {},
};
}
export function normalizeProjectPresentation(value) {
return {
icon: normalizeMedia(value?.icon),
teaser: normalizeMedia(value?.teaser),
};
}
export function normalizeEnvironmentPresentation(value) {
const legacyTeaser = normalizeMedia(value?.defaultTeaser);
return {
theme: value?.theme === "light" ? "light" : "dark",
accentHex: /^#[0-9a-f]{6}$/i.test(String(value?.accentHex || ""))
? String(value.accentHex).toLowerCase()
: DEFAULT_ACCENT,
overview: normalizeOverview(value?.overview, legacyTeaser),
};
}
function defaultOverview() {
return {
headerLabel: "Device Core",
eyebrow: "NODEDC / DEVICE CORE",
title: "Device Core",
description: "Единый контур подключения, учёта и управления устройствами.",
primarySection: "devices",
secondarySection: null,
background: {
enabled: false,
imageDurationSeconds: 10,
items: [],
},
};
}
function normalizeOverview(value, legacyTeaser) {
const fallback = defaultOverview();
const legacySource = mediaSource(legacyTeaser);
const legacyItems = legacySource ? [{
id: "legacy-overview-media",
...legacyTeaser,
mediaKind: inferMediaKind(legacySource),
}] : [];
const sourceItems = Array.isArray(value?.background?.items)
? value.background.items.slice(0, 24)
: legacyItems;
const items = sourceItems
.map(normalizeEnvironmentMediaItem)
.filter(Boolean);
return {
headerLabel: normalizeCopy(value?.headerLabel, fallback.headerLabel, 40),
eyebrow: normalizeCopy(value?.eyebrow, fallback.eyebrow, 80),
title: normalizeCopy(value?.title, fallback.title, 120),
description: normalizeCopy(value?.description, fallback.description, 500),
primarySection: normalizeSection(value?.primarySection, fallback.primarySection),
secondarySection: normalizeSection(value?.secondarySection, fallback.secondarySection),
background: {
enabled: value?.background
? Boolean(value.background.enabled)
: legacyItems.length > 0,
imageDurationSeconds: clampInteger(value?.background?.imageDurationSeconds, 1, 60, 10),
items,
},
};
}
function normalizeEnvironmentMediaItem(value) {
const media = normalizeMedia(value);
const source = mediaSource(media);
if (!source && !value?.url && !value?.fileSrc) return null;
return {
id: /^[a-z0-9][a-z0-9._:-]{0,127}$/i.test(String(value?.id || ""))
? String(value.id)
: randomUUID(),
...media,
mediaKind: value?.mediaKind === "image" || value?.mediaKind === "video"
? value.mediaKind
: inferMediaKind(source),
};
}
function normalizeCopy(value, fallback, maxLength) {
const normalized = String(value || "").trim();
return (normalized || fallback).slice(0, maxLength);
}
function normalizeSection(value, fallback) {
const allowed = new Set(["overview", "devices", "infrastructure", "management", "administration"]);
if (value === undefined) return fallback;
if (value === null || value === "none") return null;
return allowed.has(value) ? value : fallback;
}
function clampInteger(value, minimum, maximum, fallback) {
const normalized = Number.parseInt(String(value), 10);
return Number.isInteger(normalized)
? Math.min(maximum, Math.max(minimum, normalized))
: fallback;
}
function normalizePresentation(value) {
const projects = {};
if (value?.projects && typeof value.projects === "object" && !Array.isArray(value.projects)) {
for (const [projectRef, presentation] of Object.entries(value.projects)) {
if (/^project:[0-9a-f-]{36}$/i.test(projectRef)) {
projects[projectRef.toLowerCase()] = normalizeProjectPresentation(presentation);
}
}
}
return {
environment: normalizeEnvironmentPresentation(value?.environment),
projects,
};
}
function normalizeMedia(value) {
const source = value?.source === "url" ? "url" : "file";
const url = source === "url" ? safeExternalUrl(value?.url) : "";
const fileSrc = source === "file" && /^\/device-manager-media\/[a-z0-9._-]+$/i.test(String(value?.fileSrc || ""))
? String(value.fileSrc)
: null;
return {
source,
url,
fileName: fileSrc ? String(value?.fileName || basename(fileSrc)).slice(0, 180) : null,
fileSrc,
};
}
function safeExternalUrl(value) {
const candidate = String(value || "").trim();
if (!candidate) return "";
try {
const url = new URL(candidate);
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : "";
} catch {
return "";
}
}
function emptyMedia() {
return { source: "file", url: "", fileName: null, fileSrc: null };
}
function mediaSource(value) {
if (!value) return null;
return value.source === "url" ? value.url || null : value.fileSrc;
}
function inferMediaKind(value) {
const pathname = (() => {
try { return new URL(String(value || ""), "http://localhost").pathname; }
catch { return String(value || ""); }
})();
return /\.(?:png|jpe?g|webp|gif|avif)$/i.test(pathname) ? "image" : "video";
}
function allowedExtension(contentType, originalName, kind) {
const normalized = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
const imageTypes = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"], ["image/gif", ".gif"], ["image/avif", ".avif"]]);
const videoTypes = new Map([
["video/mp4", ".mp4"],
["video/webm", ".webm"],
["video/quicktime", ".mov"],
["video/x-quicktime", ".mov"],
]);
const allowed = kind === "icon"
? imageTypes
: kind === "teaser"
? videoTypes
: new Map([...imageTypes, ...videoTypes]);
const suppliedExtension = extname(String(originalName || "")).toLowerCase();
const extensionFallback = new Map([
[".png", ".png"], [".jpg", ".jpg"], [".jpeg", ".jpg"], [".webp", ".webp"],
[".gif", ".gif"], [".avif", ".avif"], [".mp4", ".mp4"], [".webm", ".webm"], [".mov", ".mov"],
]);
const extension = allowed.get(normalized)
|| (!normalized || normalized === "application/octet-stream"
? extensionFallback.get(suppliedExtension)
: null);
if (!extension) throw serviceError("device_manager_media_type_forbidden", 415);
if (suppliedExtension && kind === "icon" && ![".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"].includes(suppliedExtension)) {
throw serviceError("device_manager_media_extension_forbidden", 415);
}
if (suppliedExtension && kind === "teaser" && ![".mp4", ".webm", ".mov"].includes(suppliedExtension)) {
throw serviceError("device_manager_media_extension_forbidden", 415);
}
return extension;
}
function serviceError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
createDeviceManagerPresentationStore,
defaultPresentation,
normalizeEnvironmentPresentation,
} from "./device-manager-presentation.mjs";
test("Device Core environment defaults to the product-level canonical identity", () => {
const presentation = defaultPresentation();
assert.deepEqual(presentation.environment.overview, {
headerLabel: "Device Core",
eyebrow: "NODEDC / DEVICE CORE",
title: "Device Core",
description: "Единый контур подключения, учёта и управления устройствами.",
primarySection: "devices",
secondarySection: null,
background: {
enabled: false,
imageDurationSeconds: 10,
items: [],
},
});
});
test("legacy single teaser migrates into the environment media playlist", () => {
const environment = normalizeEnvironmentPresentation({
defaultTeaser: {
source: "file",
fileName: "legacy.mov",
fileSrc: "/device-manager-media/background-00000000-0000-4000-8000-000000000000.mov",
},
});
assert.equal(environment.overview.background.enabled, true);
assert.equal(environment.overview.background.items.length, 1);
assert.equal(environment.overview.background.items[0].mediaKind, "video");
});
test("environment media accepts MOV even when the browser omits or varies its MIME", async (t) => {
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
t.after(() => rm(root, { recursive: true, force: true }));
const store = createDeviceManagerPresentationStore({
layoutPath: join(root, "presentation.json"),
uploadRoot: join(root, "media"),
});
for (const [index, contentType] of ["video/quicktime", "video/x-quicktime", ""].entries()) {
const uploaded = await store.saveMedia({
bytes: Buffer.from(`mov-${index}`),
contentType,
originalName: `background-${index}.mov`,
kind: "background",
});
assert.match(uploaded.fileSrc, /^\/device-manager-media\/background-[0-9a-f-]+\.mov$/);
assert.equal(await readFile(store.resolveMedia(uploaded.fileSrc), "utf8"), `mov-${index}`);
}
});
test("environment presentation persists ordered mixed media and image duration", async (t) => {
const root = await mkdtemp(join(tmpdir(), "nodedc-device-presentation-"));
t.after(() => rm(root, { recursive: true, force: true }));
const store = createDeviceManagerPresentationStore({
layoutPath: join(root, "presentation.json"),
uploadRoot: join(root, "media"),
});
const next = defaultPresentation();
next.environment.overview.background = {
enabled: true,
imageDurationSeconds: 17,
items: [
{
id: "video-first",
source: "url",
url: "https://media.example/device.mov",
fileName: null,
fileSrc: null,
mediaKind: "video",
},
{
id: "image-second",
source: "url",
url: "https://media.example/device.webp",
fileName: null,
fileSrc: null,
mediaKind: "image",
},
],
};
await store.write(next);
const restored = await store.read();
assert.equal(restored.environment.overview.background.imageDurationSeconds, 17);
assert.deepEqual(
restored.environment.overview.background.items.map((item) => item.id),
["video-first", "image-second"],
);
});
@@ -0,0 +1,395 @@
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { createServer } from "node:http";
import { dirname, extname, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
import {
createDeviceCoreClient,
createLocalPreviewDeviceCore,
} from "./device-core-client.mjs";
import {
createDeviceManagerPresentationStore,
normalizeEnvironmentPresentation,
normalizeProjectPresentation,
} from "./device-manager-presentation.mjs";
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
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/devices:update", "devices:update"],
["/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",
],
["/api/device-manager/commands:service-ping", "commands:service-ping"],
]);
export function createDeviceManagerServer({
auth,
coreClient,
distRoot = resolve(appRoot, "dist"),
presentationStore = createDeviceManagerPresentationStore({
layoutPath: resolve(appRoot, "runtime-data/device-manager-presentation.json"),
uploadRoot: resolve(appRoot, "runtime-data/device-manager-media"),
}),
} = {}) {
if (!auth || typeof auth.authorize !== "function") {
throw new TypeError("device_manager_auth_required");
}
if (!coreClient || typeof coreClient.listProjects !== "function") {
throw new TypeError("device_manager_core_client_required");
}
return createServer(async (request, response) => {
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Referrer-Policy", "same-origin");
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
try {
const url = new URL(
request.url || "/",
`http://${request.headers.host || "127.0.0.1"}`,
);
if (request.method === "GET" && url.pathname === "/healthz") {
return sendJson(response, 200, {
ok: true,
service: "nodedc-device-manager",
authRequired: auth.authRequired,
deviceCoreConfigured: coreClient.configured === true,
});
}
if (request.method === "GET" && url.pathname === "/auth/nodedc/handoff") {
return auth.handleHandoff(request, response, url);
}
if (request.method === "GET" && url.pathname === "/auth/logout") {
return auth.handleLogout(request, response);
}
if (await auth.authorize(request, response, url)) return;
const context = auth.currentContext(request);
if (!context) return sendJson(response, 401, {
ok: false,
error: "device_manager_auth_required",
});
if (request.method === "GET" && url.pathname === "/api/device-manager/session") {
return sendJson(response, 200, { ok: true, session: context });
}
if (request.method === "GET" && url.pathname === "/api/device-manager/projects") {
const projects = await coreClient.listProjects(context.actor);
return sendJson(response, 200, { ok: true, projects });
}
if (request.method === "GET" && url.pathname === "/api/device-manager/presentation") {
const [presentation, projects] = await Promise.all([
presentationStore.read(),
coreClient.listProjects(context.actor),
]);
const allowed = new Set(projects.map((project) => project.projectRef));
return sendJson(response, 200, {
ok: true,
presentation: {
environment: presentation.environment,
projects: Object.fromEntries(
Object.entries(presentation.projects).filter(([ref]) => allowed.has(ref)),
),
},
});
}
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/project") {
const input = await readJsonBody(request, 128 * 1024);
const projectRef = validProjectRef(input.projectRef);
await requireProjectManage(coreClient, context.actor, projectRef);
const current = await presentationStore.read();
current.projects[projectRef] = normalizeProjectPresentation(input.presentation);
const presentation = await presentationStore.write(current);
return sendJson(response, 200, { ok: true, presentation });
}
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/environment") {
requireSuperAdmin(context.actor);
const input = await readJsonBody(request, 128 * 1024);
const current = await presentationStore.read();
current.environment = normalizeEnvironmentPresentation(input.environment);
const presentation = await presentationStore.write(current);
return sendJson(response, 200, { ok: true, presentation });
}
if (request.method === "PUT" && url.pathname === "/api/device-manager/presentation/media") {
const scope = url.searchParams.get("scope");
const kind = url.searchParams.get("kind");
if (kind !== "icon" && kind !== "teaser" && kind !== "background") {
throw serviceError("device_manager_media_kind_invalid", 400);
}
if (scope === "environment") {
requireSuperAdmin(context.actor);
if (kind !== "background") throw serviceError("device_manager_media_kind_invalid", 400);
} else if (scope === "project") {
if (kind === "background") throw serviceError("device_manager_media_kind_invalid", 400);
await requireProjectManage(coreClient, context.actor, validProjectRef(url.searchParams.get("projectRef")));
} else {
throw serviceError("device_manager_media_scope_invalid", 400);
}
const bytes = await readBody(request, kind === "icon" ? 8 * 1024 * 1024 : 256 * 1024 * 1024);
const media = await presentationStore.saveMedia({
bytes,
contentType: request.headers["content-type"],
originalName: singleOptionalHeader(request.headers["x-file-name"]),
kind,
});
return sendJson(response, 200, { ok: true, ...media });
}
if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/device-manager-media/")) {
const mediaPath = presentationStore.resolveMedia(url.pathname);
if (!mediaPath) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" });
return serveFile(request, response, mediaPath, "private, max-age=300");
}
const projectRef = workspaceProjectRef(url.pathname);
if (request.method === "GET" && projectRef) {
const workspace = await coreClient.getWorkspace(context.actor, projectRef);
return sendJson(response, 200, { ok: true, workspace });
}
const command = mutationRoutes.get(url.pathname);
if (request.method === "POST" && command) {
const idempotencyKey = singleHeader(request.headers["idempotency-key"]);
const input = await readJsonBody(request, 64 * 1024);
const execution = await coreClient.execute(
command,
context.actor,
input,
idempotencyKey,
);
response.setHeader("Idempotency-Key", idempotencyKey);
response.setHeader(
"Idempotency-Replayed",
execution.replayed ? "true" : "false",
);
return sendJson(response, 200, { ok: true, ...execution });
}
if (url.pathname.startsWith("/api/")) {
return sendJson(response, 404, { ok: false, error: "device_manager_route_not_found" });
}
return serveStatic(request, response, url, distRoot);
} catch (error) {
if (response.headersSent) {
response.destroy();
return;
}
const statusCode = normalizeStatus(error?.statusCode);
return sendJson(response, statusCode, {
ok: false,
error: safeError(error),
});
}
});
}
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false);
const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim();
const launcherInternalToken = launcherTokenFile
? (await readFile(launcherTokenFile, "utf8")).trim()
: undefined;
const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken });
if (auth.authRequired && !auth.internalAccessConfigured) {
throw new Error("device_manager_auth_token_file_required");
}
let coreClient;
if (localPreview) {
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
throw new Error("device_manager_local_preview_forbidden");
}
coreClient = createLocalPreviewDeviceCore({
fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null,
});
} else {
const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim();
if (!tokenFile) throw new Error("device_core_token_file_required");
const token = (await readFile(tokenFile, "utf8")).trim();
coreClient = createDeviceCoreClient({
baseUrl: env.NODEDC_DEVICE_CORE_INTERNAL_URL,
token,
});
}
const presentationStore = createDeviceManagerPresentationStore({
layoutPath: String(env.NODEDC_DEVICE_MANAGER_PRESENTATION_PATH || resolve(appRoot, "runtime-data/device-manager-presentation.json")),
uploadRoot: String(env.NODEDC_DEVICE_MANAGER_MEDIA_ROOT || resolve(appRoot, "runtime-data/device-manager-media")),
});
return createDeviceManagerServer({ auth, coreClient, presentationStore });
}
async function serveStatic(request, response, url, root) {
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
const candidate = resolve(root, `.${decodeURIComponent(requestedPath)}`);
const normalizedRoot = resolve(root);
if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}${sep}`)) {
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
}
let filePath = candidate;
let info = await stat(filePath).catch(() => null);
if ((!info || !info.isFile()) && !extname(requestedPath)) {
filePath = resolve(root, "index.html");
info = await stat(filePath).catch(() => null);
}
if (!info?.isFile()) {
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
}
return serveFile(request, response, filePath, filePath.endsWith("index.html") ? "no-store" : "private, max-age=300", info);
}
async function serveFile(request, response, filePath, cacheControl, existingInfo = null) {
const info = existingInfo || await stat(filePath).catch(() => null);
if (!info?.isFile()) return sendJson(response, 404, { ok: false, error: "device_manager_media_not_found" });
response.statusCode = 200;
response.setHeader("Content-Type", contentType(filePath));
response.setHeader("Cache-Control", cacheControl);
response.setHeader("Content-Length", info.size);
if (request.method === "HEAD") return response.end();
createReadStream(filePath).pipe(response);
}
function workspaceProjectRef(pathname) {
const match = pathname.match(/^\/api\/device-manager\/projects\/([^/]+)\/workspace$/);
if (!match) return null;
const projectRef = decodeURIComponent(match[1]);
return /^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectRef)
? projectRef.toLowerCase()
: null;
}
async function readJsonBody(request, maxBytes) {
const bytes = await readBody(request, maxBytes);
try {
const body = JSON.parse(bytes.toString("utf8") || "{}");
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
return body;
} catch {
throw serviceError("device_manager_json_invalid", 400);
}
}
async function readBody(request, maxBytes) {
let size = 0;
const chunks = [];
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) throw serviceError("device_manager_request_too_large", 413);
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function validProjectRef(value) {
const normalized = String(value || "").trim().toLowerCase();
if (!/^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
throw serviceError("device_project_ref_invalid", 400);
}
return normalized;
}
async function requireProjectManage(coreClient, actor, projectRef) {
const workspace = await coreClient.getWorkspace(actor, projectRef);
if (!workspace.project.access.capabilities.includes("project.manage")) {
throw serviceError("device_project_capability_denied", 403);
}
}
function requireSuperAdmin(actor) {
const groups = new Set(actor.groupRefs || []);
if (!groups.has("group:nodedc:superadmin") && !groups.has("nodedc:superadmin")) {
throw serviceError("device_environment_settings_denied", 403);
}
}
function singleHeader(value) {
if (Array.isArray(value) || typeof value !== "string") {
throw serviceError("device_idempotency_key_invalid", 400);
}
const normalized = value.trim();
if (!/^[\x21-\x7e]{8,256}$/.test(normalized)) {
throw serviceError("device_idempotency_key_invalid", 400);
}
return normalized;
}
function singleOptionalHeader(value) {
if (value == null) return "";
if (Array.isArray(value) || typeof value !== "string") throw serviceError("device_manager_header_invalid", 400);
return value.trim();
}
function sendJson(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify(body));
}
function contentType(pathname) {
return ({
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".avif": "image/avif",
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".ico": "image/x-icon",
})[extname(pathname).toLowerCase()] || "application/octet-stream";
}
function normalizeStatus(value) {
const status = Number(value || 500);
return Number.isInteger(status) && status >= 400 && status < 600 ? status : 500;
}
function safeError(error) {
const value = String(error?.message || "");
return /^(?:device|nodedc)_[a-z0-9._:-]{2,160}$/.test(value)
? value
: "device_manager_internal_error";
}
function booleanValue(value, fallback) {
if (value == null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
function serviceError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const server = await createConfiguredDeviceManagerServer();
const port = Number.parseInt(process.env.PORT || "3335", 10);
const host = String(process.env.HOST || "127.0.0.1");
server.listen(port, host, () => {
console.log(JSON.stringify({
event: "device_manager_started",
host,
port,
}));
});
}
@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
import {
createConfiguredDeviceManagerServer,
createDeviceManagerServer,
} from "./device-manager-server.mjs";
test("production configuration starts with runner-owned file tokens", async (t) => {
const root = await mkdtemp(join(tmpdir(), "nodedc-device-manager-production-"));
const launcherTokenFile = join(root, "launcher-token");
const coreTokenFile = join(root, "core-token");
await writeFile(launcherTokenFile, `${"a".repeat(48)}\n`, { mode: 0o640 });
await writeFile(coreTokenFile, `${"b".repeat(48)}\n`, { mode: 0o640 });
t.after(() => rm(root, { recursive: true, force: true }));
const server = await createConfiguredDeviceManagerServer({
env: {
NODE_ENV: "production",
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true",
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false",
NODEDC_DEVICE_MANAGER_SERVICE_SLUG: "device-core",
NODEDC_LAUNCHER_BASE_URL: "https://hub.nodedc.ru",
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher:5173",
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: launcherTokenFile,
NODEDC_DEVICE_CORE_INTERNAL_URL: "http://device-control-core:18120",
NODEDC_DEVICE_CORE_TOKEN_FILE: coreTokenFile,
},
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
const response = await fetch(`http://127.0.0.1:${address.port}/healthz`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
ok: true,
service: "nodedc-device-manager",
authRequired: true,
deviceCoreConfigured: true,
});
const rootResponse = await fetch(`http://127.0.0.1:${address.port}/`, {
redirect: "manual",
headers: { accept: "text/html" },
});
assert.equal(rootResponse.status, 302);
assert.equal(
rootResponse.headers.get("location"),
"https://hub.nodedc.ru/auth/login?returnTo=%2Fapi%2Fservices%2Fdevice-core%2Flaunch%3FreturnTo%3D%252F",
);
const healthAfterRedirect = await fetch(`http://127.0.0.1:${address.port}/healthz`);
assert.equal(healthAfterRedirect.status, 200);
assert.equal((await healthAfterRedirect.json()).ok, true);
});
test("Device Manager BFF exposes an empty, mutation-driven project workspace", async (t) => {
const auth = createDeviceManagerAuth({
env: { NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "false" },
});
const coreClient = createLocalPreviewDeviceCore();
const server = createDeviceManagerServer({ auth, coreClient });
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
const session = await getJson(`${baseUrl}/api/device-manager/session`);
assert.equal(session.session.actor.hubRole, "owner");
assert.equal(session.session.actor.userRef, "user:local-device-admin");
assert.deepEqual((await getJson(`${baseUrl}/api/device-manager/projects`)).projects, []);
await postJson(`${baseUrl}/api/device-manager/owner-scopes:ensure`, {
scopeKind: "personal",
ownerRef: "user:local-device-admin",
displayName: "Local Device Admin",
}, {
"X-NODEDC-Hub-Role": "viewer",
"X-NODEDC-User-Ref": "user:spoofed-browser",
});
const created = await postJson(`${baseUrl}/api/device-manager/projects:ensure`, {
scopeKind: "personal",
ownerRef: "user:local-device-admin",
projectKey: "device-sandbox",
name: "Device sandbox",
description: "Created only through the canonical command path",
});
const projectRef = created.result.project.projectRef;
const projects = (await getJson(`${baseUrl}/api/device-manager/projects`)).projects;
assert.equal(projects.length, 1);
assert.equal(projects[0].projectKey, "device-sandbox");
assert.equal(projects[0].ownerScope.ownerRef, "user:local-device-admin");
await postJson(`${baseUrl}/api/device-manager/collections:ensure`, {
projectRef,
collectionKey: "pilot-devices",
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`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(missingKey.status, 400);
assert.equal((await missingKey.json()).error, "device_idempotency_key_invalid");
});
async function getJson(url) {
const response = await fetch(url, { headers: { accept: "application/json" } });
const body = await response.json();
assert.equal(response.status, 200, JSON.stringify(body));
assert.equal(body.ok, true);
return body;
}
async function postJson(url, body, headers = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"idempotency-key": `device-manager-test-${crypto.randomUUID()}`,
...headers,
},
body: JSON.stringify(body),
});
const payload = await response.json();
assert.equal(response.status, 200, JSON.stringify(payload));
assert.equal(payload.ok, true);
return payload;
}
@@ -0,0 +1,957 @@
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,
sendServicePing,
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();
};
const mutateAndRefresh = async (mutation: () => Promise<unknown>) => {
try {
await mutation();
await onRefresh();
} catch (reason) {
onError(reason);
}
};
return (
<>
{view === "catalog" ? (
<CatalogView
workspace={workspace}
canManage={platformOwner}
onCreatePackage={() => setDialog("adapter-package")}
onCreateVersion={() => setDialog("adapter-version")}
onCreateProfile={() => setDialog("model-profile")}
onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({
adapterPackageRef: version.adapterPackageRef,
version: version.version,
runtimePackageRef: version.runtimePackageRef,
contentDigest: version.contentDigest,
contractVersion: version.contractVersion,
capabilities: version.capabilities,
lifecycleState: "active",
}))}
onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({
adapterVersionRef: profile.adapterVersionRef || "",
profileRef: profile.modelProfileRef,
schemaVersion: profile.schemaVersion,
vendor: profile.vendor,
model: profile.model,
deviceType: profile.deviceType,
protocol: profile.protocol,
schemaArtifactRef: profile.schemaArtifactRef || "",
profileDigest: profile.profileDigest || "",
capabilities: profile.capabilities,
lifecycleState: "active",
}))}
/>
) : null}
{view === "infrastructure" ? (
<InfrastructureView
workspace={workspace}
canManageCatalog={platformOwner}
canManageRoutes={capabilities.has("route.manage")}
onCreateEdge={() => setDialog("edge")}
onCreateRoute={() => setDialog("route")}
onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({
edgeKey: edge.edgeKey,
displayName: edge.displayName,
deploymentRef: edge.deploymentRef,
lifecycleState: "active",
}))}
onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({
projectRef: workspace.project.projectRef,
routeKey: route.routeKey,
displayName: route.displayName,
edgeRef: route.edgeRef,
modelProfileRef: route.modelProfileRef,
listenerRef: route.listenerRef,
protocol: route.protocol,
direction: route.direction,
lifecycleState: "active",
}))}
/>
) : 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}
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
onRefresh={onRefresh}
onError={onError}
/>
) : 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, onActivateVersion, onActivateProfile }: {
workspace: ProjectWorkspace;
canManage: boolean;
onCreatePackage: () => void;
onCreateVersion: () => void;
onCreateProfile: () => void;
onActivateVersion: (version: AdapterVersionView) => void;
onActivateProfile: (profile: ModelProfileView) => 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}
action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? (
<Button size="compact" variant="primary" onClick={() => onActivateProfile(profile)}>Активировать</Button>
) : null}
/>
))}
</ResourceGrid>
</ControlSection>
<ControlSection title="Adapter versions" count={workspace.adapterVersions.length}>
<ResourceGrid empty="Версии адаптеров не зарегистрированы.">
{workspace.adapterVersions.map((version) => (
<ResourceCard
key={version.adapterVersionRef}
eyebrow={version.contractVersion}
title={version.version}
description={version.runtimePackageRef}
status={version.lifecycleState}
meta={[shortDigest(version.contentDigest), ...version.capabilities]}
action={canManage && version.lifecycleState === "draft" ? (
<Button size="compact" variant="primary" onClick={() => onActivateVersion(version)}>Активировать</Button>
) : null}
/>
))}
</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, onActivateEdge, onActivateRoute }: {
workspace: ProjectWorkspace;
canManageCatalog: boolean;
canManageRoutes: boolean;
onCreateEdge: () => void;
onCreateRoute: () => void;
onActivateEdge: (edge: EdgeView) => void;
onActivateRoute: (route: ProjectWorkspace["routes"][number]) => 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} активных сессий`,
]}
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
) : null}
/>
))}
</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] : []}
action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? (
<Button size="compact" variant="primary" onClick={() => onActivateEdge(edge)}>Активировать</Button>
) : null}
/>
))}
</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, canDispatch, onRefresh, onError }: {
workspace: ProjectWorkspace;
canDispatch: boolean;
onRefresh: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const supportedDevices = workspace.devices.filter(
(device) => device.modelProfileRef === "arusnavi.b2.internal.v1"
&& !["suspended", "retired"].includes(device.lifecycleState),
);
const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? "");
const [accessCode, setAccessCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const enabled = workspace.policies.commandTransport === "typed-service-ping-v1";
useEffect(() => {
if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) {
setDeviceRef(supportedDevices[0]?.deviceRef ?? "");
}
}, [deviceRef, supportedDevices]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return;
setSubmitting(true);
try {
await sendServicePing({
projectRef: workspace.project.projectRef,
deviceRef,
accessCode,
expiresInSeconds: 300,
});
setAccessCode("");
await onRefresh();
} catch (reason) {
onError(reason);
} finally {
setSubmitting(false);
}
};
return (
<ControlStack>
<GlassSurface className="device-control-command-policy" padding="md" tone="soft">
<Icon name={enabled ? "check" : "lock"} />
<div>
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
<p>{enabled
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
</div>
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
</GlassSurface>
{enabled ? (
<GlassSurface padding="md" tone="soft">
<form className="device-control-command-form" onSubmit={submit}>
<Select
label="B2 трекер"
value={deviceRef}
onChange={setDeviceRef}
options={supportedDevices.map((device) => ({
value: device.deviceRef,
label: device.displayName,
description: device.session?.state || device.lifecycleState,
}))}
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
/>
<TextField
label="Код устройства"
type="password"
inputMode="numeric"
autoComplete="off"
value={accessCode}
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
pattern="[0-9]{6}"
minLength={6}
maxLength={6}
required
disabled={!canDispatch || submitting}
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
/>
<Button
type="submit"
variant="primary"
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
>
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
</Button>
</form>
</GlassSurface>
) : null}
<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, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{action}</>}>
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <StatusBadge key={item}>{item}</StatusBadge>)}</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)}`;
}
@@ -0,0 +1,676 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
GlassSurface,
Icon,
IconButton,
Select,
SettingsCard,
StatusBadge,
Switch,
TextField,
} from "@nodedc/ui-react";
import {
createConfigurationRevision,
setDesiredConfiguration,
updateDevice,
} from "./api";
import {
accessLabel,
getDeviceProfileCatalog,
type DeviceFieldAccess,
type DeviceProfileField,
} from "./deviceProfileCatalog";
import type {
DeviceView,
ProjectWorkspace,
SessionView,
} from "./types";
export type DeviceInventoryDetailState = {
deviceRef: string;
sectionId: string;
editing: boolean;
};
export function DeviceDetailHeaderTools({
device,
detail,
canEdit,
onDetailChange,
}: {
device: DeviceView;
detail: DeviceInventoryDetailState;
canEdit: boolean;
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const activeSection = catalog.sections.find((section) => section.id === detail.sectionId)
?? catalog.sections[0];
return (
<div className="device-detail-header-tools">
<Select
className="device-detail-section-select"
label="Раздел устройства"
value={activeSection?.id ?? catalog.sections[0]?.id ?? "passport"}
options={catalog.sections.map((section) => ({
value: section.id,
label: section.label,
}))}
onChange={(sectionId) => onDetailChange({ ...detail, sectionId })}
placement="bottom-end"
minMenuWidth={320}
menuWidth="anchor"
variant="split"
/>
<IconButton
label={detail.editing ? "Завершить редактирование" : "Редактировать устройство"}
disabled={!canEdit}
data-active={detail.editing || undefined}
onClick={() => onDetailChange({
...detail,
editing: !detail.editing,
})}
>
<Icon name="edit" size={17} />
</IconButton>
</div>
);
}
export function DeviceInventoryView({
workspace,
canClaim,
canConfigure,
canManageProject,
detail,
onClaim,
onPoll,
onError,
onDetailChange,
}: {
workspace: ProjectWorkspace;
canClaim: boolean;
canConfigure: boolean;
canManageProject: boolean;
detail: DeviceInventoryDetailState | null;
onClaim: (enrollment: ProjectWorkspace["enrollments"][number]) => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
}) {
const [statusFilter, setStatusFilter] = useState("all");
const [sortOrder, setSortOrder] = useState("activity");
const selectedDevice = workspace.devices.find(
(device) => device.deviceRef === detail?.deviceRef,
) ?? null;
const pendingEnrollments = workspace.enrollments.filter(
(enrollment) => enrollment.lifecycleState !== "claimed",
);
const deviceRows = useMemo(() => workspace.devices
.map((device) => {
const session = latestSession(workspace, device);
const online = session?.lifecycleState === "online" || device.session?.state === "online";
const lastSeenAt = session?.lastSeenAt || device.session?.lastSeenAt || device.updatedAt;
return { device, session, online, lastSeenAt };
})
.filter((row) => {
if (statusFilter === "active") return row.online;
if (statusFilter === "inactive") return !row.online;
return statusFilter !== "pending";
})
.sort((left, right) => {
if (sortOrder === "name") {
return left.device.displayName.localeCompare(right.device.displayName, "ru");
}
if (sortOrder === "activity" && left.online !== right.online) {
return left.online ? -1 : 1;
}
return String(right.lastSeenAt || "").localeCompare(String(left.lastSeenAt || ""));
}), [sortOrder, statusFilter, workspace]);
useEffect(() => {
if (detail?.deviceRef && !selectedDevice) onDetailChange(null);
}, [detail?.deviceRef, onDetailChange, selectedDevice]);
useEffect(() => {
if (!detail?.deviceRef) return undefined;
const poll = () => {
if (document.visibilityState === "visible") onPoll().catch(onError);
};
const timer = window.setInterval(poll, 5_000);
return () => window.clearInterval(timer);
}, [detail?.deviceRef, onError, onPoll]);
if (selectedDevice && detail) {
return (
<DeviceDetailView
device={selectedDevice}
detail={detail}
workspace={workspace}
canConfigure={canConfigure}
canManageProject={canManageProject}
onDetailChange={onDetailChange}
onSaved={onPoll}
onError={onError}
onBack={() => onDetailChange(null)}
/>
);
}
return (
<div className="device-inventory">
<div className="device-manager-panel-toolbar device-inventory__toolbar">
<div>
<strong>Реестр устройств</strong>
<p>{workspace.devices.length} зарегистрировано · {pendingEnrollments.length} ожидают подключения</p>
</div>
</div>
<div className="device-inventory__filters" aria-label="Фильтры устройств">
<Select
label="Состояние"
value={statusFilter}
options={[
{ value: "all", label: "Все устройства" },
{ value: "active", label: "Активные" },
{ value: "inactive", label: "Неактивные" },
{ value: "pending", label: "Ожидают подключения" },
]}
onChange={setStatusFilter}
/>
<Select
label="Сортировка"
value={sortOrder}
options={[
{ value: "activity", label: "Сначала активные" },
{ value: "last-seen", label: "По последней активности" },
{ value: "name", label: "По имени" },
]}
onChange={setSortOrder}
disabled={statusFilter === "pending"}
/>
</div>
{statusFilter !== "pending" && !deviceRows.length ? (
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
<Icon name="inbox" size={24} />
<h3>{workspace.devices.length ? "Устройств с таким состоянием нет" : "В проекте пока нет устройств"}</h3>
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому процессу подключения.</p>
</GlassSurface>
) : statusFilter !== "pending" ? (
<GlassSurface className="device-inventory-table" padding="sm" tone="soft" role="table" aria-label="Устройства проекта">
<div className="device-inventory-table__head" role="row">
<span role="columnheader">Устройство</span>
<span role="columnheader">Профиль</span>
<span role="columnheader">IMEI</span>
<span role="columnheader">ID интеграционного устройства</span>
<span role="columnheader">Канал</span>
<span role="columnheader">Последний пакет</span>
<span aria-hidden="true" />
</div>
{deviceRows.map(({ device, session, online, lastSeenAt }) => {
return (
<Button
key={device.deviceRef}
type="button"
variant="ghost"
className="device-inventory-row"
role="row"
onClick={() => onDetailChange({
deviceRef: device.deviceRef,
sectionId: getDeviceProfileCatalog(device.modelProfileRef).sections[0]?.id ?? "passport",
editing: false,
})}
>
<span className="device-inventory-row__device" role="cell">
<span className="device-inventory-row__icon"><Icon name="apps" size={17} /></span>
<span><strong>{device.displayName}</strong><small>{device.deviceKey || "ключ не назначен"}</small></span>
</span>
<span role="cell">{profileLabel(workspace, device)}</span>
<span role="cell">{deviceIdentifierDisplayValue(device) || "не назначен"}</span>
<span role="cell">{device.integrationDeviceId || "не назначен"}</span>
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
<span role="cell">{formatDate(lastSeenAt)}</span>
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
</Button>
);
})}
</GlassSurface>
) : null}
{(statusFilter === "all" || statusFilter === "pending") ? (
<section className="device-inventory__pending" aria-label="Ожидают подключения">
<div className="device-inventory__section-heading">
<div>
<strong>Ожидают подключения</strong>
<p>Разрешённые идентификаторы и обнаруженные устройства.</p>
</div>
<StatusBadge tone={pendingEnrollments.length ? "warning" : "neutral"}>{pendingEnrollments.length}</StatusBadge>
</div>
{pendingEnrollments.length ? pendingEnrollments.map((enrollment) => (
<SettingsCard
key={enrollment.enrollmentIntentRef}
eyebrow={enrollment.lifecycleState}
title={enrollment.displayName}
description={`${enrollment.modelProfileRef} · ${enrollment.expectedIdentifier.masked}`}
actions={enrollment.lifecycleState === "observed" && enrollment.observedDiscoveryRef ? (
<Button size="compact" variant="primary" disabled={!canClaim} onClick={() => onClaim(enrollment)}>
Принять устройство
</Button>
) : <StatusBadge>{enrollment.lifecycleState}</StatusBadge>}
>
<p className="device-manager-card-copy">После первого пакета устройство можно принять в реестр. Исходный идентификатор в интерфейсе не раскрывается.</p>
</SettingsCard>
)) : (
<GlassSurface className="device-manager-panel-empty device-inventory__pending-empty" padding="md" tone="soft">
Нет ожидающих подключений.
</GlassSurface>
)}
</section>
) : null}
</div>
);
}
function DeviceDetailView({
device,
detail,
workspace,
canConfigure,
canManageProject,
onDetailChange,
onSaved,
onError,
onBack,
}: {
device: DeviceView;
detail: DeviceInventoryDetailState;
workspace: ProjectWorkspace;
canConfigure: boolean;
canManageProject: boolean;
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
onSaved: () => Promise<void>;
onError: (reason: unknown) => void;
onBack: () => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const detailRef = useRef<HTMLDivElement>(null);
const [draftValues, setDraftValues] = useState<Record<string, string | boolean>>({});
const [saving, setSaving] = useState(false);
const profile = workspace.modelProfiles.find(
(item) => item.modelProfileRef === device.modelProfileRef,
) ?? null;
const session = latestSession(workspace, device);
const configurationState = workspace.configurationStates.find(
(item) => item.deviceRef === device.deviceRef,
) ?? null;
const identifierDisplayValue = deviceIdentifierDisplayValue(device);
const context = useMemo(() => ({
device: {
...device,
identifier: device.identifier ? {
...device.identifier,
displayValue: identifierDisplayValue,
} : null,
},
profile,
session,
configurationState,
reported: device.reported ?? {},
policies: {
...workspace.policies,
firmwareUpdate: "blocked",
},
}), [configurationState, device, identifierDisplayValue, profile, session, workspace.policies]);
const activeSection = catalog.sections.find(
(section) => section.id === detail.sectionId,
) ?? catalog.sections[0];
useEffect(() => {
if (!catalog.sections.some((section) => section.id === detail.sectionId)) {
onDetailChange({
...detail,
sectionId: catalog.sections[0]?.id ?? "passport",
});
}
}, [catalog.sections, detail, onDetailChange]);
useEffect(() => {
setDraftValues({});
}, [detail.editing, device.deviceRef]);
useEffect(() => {
const panelBody = detailRef.current?.closest<HTMLElement>(".nodedc-application-panel__body");
if (panelBody) panelBody.scrollTop = 0;
}, [detail.sectionId, device.deviceRef]);
if (!activeSection) return null;
const saveDeviceChanges = async () => {
if (!(canConfigure || canManageProject) || !Object.keys(draftValues).length) return;
setSaving(true);
try {
const displayNameDraft = draftValues["device.displayName"];
const integrationDeviceIdDraft = draftValues["device.integrationDeviceId"];
const nextDisplayName = typeof displayNameDraft === "string"
? displayNameDraft.trim()
: device.displayName;
const nextIntegrationDeviceId = typeof integrationDeviceIdDraft === "string"
? integrationDeviceIdDraft.trim() || null
: device.integrationDeviceId;
if (
canManageProject
&& nextDisplayName
&& (
nextDisplayName !== device.displayName
|| nextIntegrationDeviceId !== device.integrationDeviceId
)
) {
await updateDevice({
projectRef: workspace.project.projectRef,
deviceRef: device.deviceRef,
displayName: nextDisplayName,
integrationDeviceId: nextIntegrationDeviceId,
});
}
const configurationDrafts = Object.entries(draftValues).filter(([path]) =>
path.startsWith("reported.configuration."),
);
if (configurationDrafts.length) {
const nextConfiguration = cloneConfiguration(device.reported?.configuration);
for (const [path, value] of configurationDrafts) {
const field = catalog.sections.flatMap((section) => section.fields)
.find((item) => item.path === path);
if (!field || !isDeviceFieldEditable(field, field.access ?? activeSection.access, {
canConfigure,
canManageProject,
})) continue;
writePath(
nextConfiguration,
path.replace(/^reported\.configuration\./, ""),
normalizeDraftValue(value, field),
);
}
const created = await createConfigurationRevision({
projectRef: workspace.project.projectRef,
deviceRef: device.deviceRef,
configuration: nextConfiguration,
changeSummary: `Device Manager · ${activeSection.title}`,
});
await setDesiredConfiguration({
projectRef: workspace.project.projectRef,
deviceRef: device.deviceRef,
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
});
}
setDraftValues({});
onDetailChange({ ...detail, editing: false });
await onSaved();
} catch (reason) {
onError(reason);
} finally {
setSaving(false);
}
};
return (
<div ref={detailRef} className="device-detail">
<div className="device-detail__header">
<IconButton label="Вернуться к списку устройств" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
<div className="device-detail__identity">
<small>{catalog.vendor} · {catalog.model}</small>
<h2>{device.displayName}</h2>
<p>{identifierDisplayValue || "Идентификатор не назначен"} · {device.modelProfileRef}</p>
</div>
<StatusBadge tone={session?.lifecycleState === "online" ? "success" : "neutral"}>
{session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
</StatusBadge>
</div>
<div className="device-detail__legend" aria-label="Режимы доступа">
<AccessBadge access="read-only" />
<AccessBadge access="managed" />
<AccessBadge access="protected" />
</div>
<GlassSurface className="device-detail__connection" padding="md" tone="soft">
<div><span>Состояние связи</span><strong>{session?.lifecycleState || device.session?.state || "Нет сессии"}</strong></div>
<div><span>Маршрут</span><strong>{session?.routeName || "Не определён"}</strong></div>
<div><span>Протокол</span><strong>{session?.protocol || profile?.protocol || "Нет данных"}</strong></div>
<div><span>Последняя активность</span><strong>{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</strong></div>
<div><span>Пакеты</span><strong>{session?.frameCount ?? 0}</strong></div>
<div><span>Подключено</span><strong>{formatDate(session?.connectedAt)}</strong></div>
</GlassSurface>
<div className="device-detail__layout">
<section className="device-detail-section">
<div className="device-detail-section__heading">
<div>
<span>{catalog.title}</span>
<h3>{activeSection.title}</h3>
<p>{activeSection.description}</p>
</div>
<AccessBadge access={activeSection.access} />
</div>
<AccessNotice
access={activeSection.access}
commandTransport={workspace.policies.commandTransport}
/>
<div className="device-detail-fields">
{activeSection.fields.map((item) => {
const access = item.access ?? activeSection.access;
const editable = detail.editing
&& isDeviceFieldEditable(item, access, {
canConfigure,
canManageProject,
});
const value = Object.prototype.hasOwnProperty.call(draftValues, item.path)
? draftValues[item.path]
: readPath(context, item.path);
return (
<GlassSurface key={item.key} className="device-detail-field" padding="sm" tone="soft" data-access={access} data-editing={editable || undefined}>
{editable && item.valueKind === "boolean" ? (
<Switch
checked={Boolean(value)}
label={item.label}
disabled={saving}
onChange={(checked) => setDraftValues((current) => ({ ...current, [item.path]: checked }))}
/>
) : editable ? (
<TextField
label={item.label}
hint={item.unit}
description={item.description}
type={item.valueKind === "number" ? "number" : "text"}
value={value === undefined || value === null ? "" : String(value)}
disabled={saving}
onChange={(event) => setDraftValues((current) => ({ ...current, [item.path]: event.target.value }))}
/>
) : (
<>
<div className="device-detail-field__label">
<span>{item.label}</span>
{access !== activeSection.access ? <AccessBadge access={access} compact /> : null}
</div>
<strong>{formatFieldValue(value, item)}</strong>
{item.description ? <small>{item.description}</small> : null}
</>
)}
</GlassSurface>
);
})}
</div>
{detail.editing ? (
<div className="device-detail-edit-actions">
<Button
type="button"
variant="ghost"
disabled={saving}
onClick={() => {
setDraftValues({});
onDetailChange({ ...detail, editing: false });
}}
>
Отменить
</Button>
<Button
type="button"
variant="primary"
icon={<Icon name="save" />}
disabled={saving || !Object.keys(draftValues).length}
onClick={() => void saveDeviceChanges()}
>
{saving ? "Сохраняем…" : "Сохранить"}
</Button>
</div>
) : null}
<div className="device-detail-section__state">
<span>Desired</span>
<strong>{configurationState?.desiredConfigurationRevisionRef || "Не задано"}</strong>
<span>Applied</span>
<strong>{configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}</strong>
</div>
</section>
</div>
</div>
);
}
function isDeviceFieldEditable(
field: DeviceProfileField,
access: DeviceFieldAccess,
capabilities: { canConfigure: boolean; canManageProject: boolean } = {
canConfigure: true,
canManageProject: true,
},
) {
return access === "managed"
&& (
(["device.displayName", "device.integrationDeviceId"].includes(field.path) && capabilities.canManageProject)
|| (field.path.startsWith("reported.configuration.") && capabilities.canConfigure)
)
&& !field.sensitive;
}
function deviceIdentifierDisplayValue(device: DeviceView) {
if (!device.identifier) return null;
if (device.identifier.value) return device.identifier.value;
const reportedImei = device.reported?.identity?.imei;
if (typeof reportedImei === "string" && reportedImei.trim()) return reportedImei;
return device.identifier.masked;
}
function cloneConfiguration(configuration: Record<string, unknown> | null | undefined) {
if (!configuration) return {};
return JSON.parse(JSON.stringify(configuration)) as Record<string, unknown>;
}
function writePath(target: Record<string, unknown>, path: string, value: unknown) {
const keys = path.split(".");
let cursor: Record<string, unknown> | unknown[] = target;
keys.forEach((key, index) => {
if (index === keys.length - 1) {
if (Array.isArray(cursor)) cursor[Number(key)] = value;
else cursor[key] = value;
return;
}
const nextKey = keys[index + 1];
const nextValue = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key];
if (!nextValue || typeof nextValue !== "object") {
const created: Record<string, unknown> | unknown[] = /^\d+$/.test(nextKey) ? [] : {};
if (Array.isArray(cursor)) cursor[Number(key)] = created;
else cursor[key] = created;
cursor = created;
} else {
cursor = nextValue as Record<string, unknown> | unknown[];
}
});
}
function normalizeDraftValue(value: string | boolean, field: DeviceProfileField) {
if (field.valueKind === "number") return value === "" ? null : Number(value);
return value;
}
function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
return <StatusBadge className={compact ? "device-access-badge--compact" : undefined} tone={tone}>{accessLabel(access)}</StatusBadge>;
}
function AccessNotice({
access,
commandTransport,
}: {
access: DeviceFieldAccess;
commandTransport: ProjectWorkspace["policies"]["commandTransport"];
}) {
if (access === "read-only") {
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></GlassSurface>;
}
if (access === "protected") {
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></GlassSurface>;
}
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Изменение создаёт новую desired-ревизию. Статус Applied появится только после подтверждения устройством." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></GlassSurface>;
}
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef);
return sessions.sort((left, right) => {
if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1;
if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1;
return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || ""));
})[0] ?? null;
}
function profileLabel(workspace: ProjectWorkspace, device: DeviceView) {
const profile = workspace.modelProfiles.find(
(item) => item.modelProfileRef === device.modelProfileRef,
);
return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef;
}
function readPath(input: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((value, key) => {
if (!value || typeof value !== "object") return undefined;
return (value as Record<string, unknown>)[key];
}, input);
}
function formatFieldValue(value: unknown, item: DeviceProfileField) {
if (value === undefined || value === null || value === "") return "Нет данных";
if (item.sensitive) return "Задано · значение скрыто";
if (item.valueKind === "date") return formatDate(String(value));
if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено";
if (value === "blocked") return "Запрещено";
if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных";
if (typeof value === "object") return JSON.stringify(value);
return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`;
}
function formatDate(value: string | null | undefined) {
if (!value) return "Нет данных";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
export const __deviceInventoryTestables = {
formatFieldValue,
readPath,
} as const;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,222 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Icon,
IconButton,
MediaSourceField,
RangeControl,
SortableList,
} from "@nodedc/ui-react";
import type {
DeviceManagerEnvironmentMediaItem,
DeviceManagerEnvironmentOverview,
DeviceManagerMediaKind,
DeviceManagerMediaSource,
} from "./types";
type EnvironmentBackground = DeviceManagerEnvironmentOverview["background"];
interface EnvironmentMediaPlaylistEditorProps {
background: EnvironmentBackground;
disabled: boolean;
error: string | null;
onChange: (background: EnvironmentBackground) => void;
onBusyChange: (busy: boolean) => void;
onUpload: (itemId: string, file: File) => Promise<{ fileName: string; fileSrc: string }>;
}
const acceptedEnvironmentMedia = [
"image/png", "image/jpeg", "image/gif", "image/webp", "image/avif",
"video/mp4", "video/webm", "video/quicktime", "video/x-quicktime",
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".mp4", ".webm", ".mov",
].join(",");
const maxEnvironmentMediaItems = 24;
function inferMediaKind(value: string): DeviceManagerMediaKind {
const pathname = (() => {
try { return new URL(value, window.location.origin).pathname; }
catch { return value; }
})();
return /\.(?:png|jpe?g|gif|webp|avif)$/i.test(pathname) ? "image" : "video";
}
function createMediaItem(): DeviceManagerEnvironmentMediaItem {
return {
id: globalThis.crypto?.randomUUID?.() ?? `media-${Date.now()}-${Math.random().toString(36).slice(2)}`,
source: "file",
url: "",
fileName: null,
fileSrc: null,
mediaKind: null,
};
}
function patchItem(
background: EnvironmentBackground,
itemId: string,
patch: Partial<DeviceManagerEnvironmentMediaItem>,
): EnvironmentBackground {
return {
...background,
items: background.items.map((item) => item.id === itemId ? { ...item, ...patch } : item),
};
}
function mediaSource(item: DeviceManagerEnvironmentMediaItem) {
return item.source === "url" ? item.url || null : item.fileSrc;
}
export function EnvironmentMediaPlaylistEditor({
background,
disabled,
error,
onChange,
onBusyChange,
onUpload,
}: EnvironmentMediaPlaylistEditorProps) {
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
const backgroundRef = useRef(background);
backgroundRef.current = background;
const displayedItems = useMemo(() => [...background.items].reverse(), [background.items]);
useEffect(() => onBusyChange(uploadingIds.size > 0), [onBusyChange, uploadingIds.size]);
useEffect(() => () => onBusyChange(false), [onBusyChange]);
const setItemError = (itemId: string, message?: string) => {
setItemErrors((current) => {
const next = { ...current };
if (message) next[itemId] = message;
else delete next[itemId];
return next;
});
};
const uploadFile = async (itemId: string, file?: File) => {
if (!file) return;
setUploadingIds((current) => new Set(current).add(itemId));
setItemError(itemId);
try {
const stored = await onUpload(itemId, file);
onChange(patchItem(backgroundRef.current, itemId, {
source: "file",
url: "",
fileName: stored.fileName,
fileSrc: stored.fileSrc,
mediaKind: inferMediaKind(file.name),
}));
} catch (reason) {
setItemError(itemId, reason instanceof Error ? reason.message : "Не удалось загрузить медиаконтент.");
} finally {
setUploadingIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
};
return (
<div className="environment-media-playlist">
<div className="environment-media-playlist__head">
<div>
<span>Видео / картинка</span>
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
</div>
<IconButton
label="Добавить медиаконтент"
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
onClick={() => onChange({ ...background, items: [...background.items, createMediaItem()] })}
>
<Icon name="plus" />
</IconButton>
</div>
{displayedItems.length ? (
<SortableList
items={displayedItems}
getId={(item) => item.id}
className="environment-media-playlist__items"
onReorder={(items) => onChange({ ...background, items: [...items].reverse() })}
>
{(item, { handle }) => {
const playbackIndex = background.items.findIndex((candidate) => candidate.id === item.id);
const preview = mediaSource(item);
return (
<div className="environment-media-playlist__item">
<MediaSourceField
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
kindLabel={item.mediaKind ?? "media"}
source={item.source}
url={item.url}
fileName={item.fileName}
uploading={uploadingIds.has(item.id)}
previewSrc={preview}
previewKind={item.mediaKind}
accept={acceptedEnvironmentMedia}
path={`overview.background.items[${playbackIndex}] → Device Core media`}
hint="Файл сохраняется в data root Device Core. URL должен вести прямо на media по HTTP(S)."
error={itemErrors[item.id] ?? (playbackIndex === background.items.length - 1 ? error : null)}
onSourceChange={(source: DeviceManagerMediaSource) => {
if (source === item.source) return;
setItemError(item.id);
onChange(patchItem(background, item.id, {
source,
url: "",
fileName: null,
fileSrc: null,
mediaKind: null,
}));
}}
onUrlChange={(url) => {
setItemError(item.id);
onChange(patchItem(background, item.id, {
source: "url",
url,
fileName: null,
fileSrc: null,
mediaKind: url ? inferMediaKind(url) : null,
}));
}}
onFileChange={(file) => void uploadFile(item.id, file)}
/>
<div className="environment-media-playlist__item-actions">
<IconButton
label={`Удалить медиаконтент ${playbackIndex + 1}`}
disabled={disabled || uploadingIds.has(item.id)}
onClick={() => {
setItemError(item.id);
onChange({ ...background, items: background.items.filter((candidate) => candidate.id !== item.id) });
}}
>
<Icon name="trash" />
</IconButton>
{handle}
</div>
</div>
);
}}
</SortableList>
) : (
<>
<p className="environment-media-playlist__empty">Добавьте первый файл или прямую ссылку на медиаконтент.</p>
{error ? <p className="environment-media-playlist__error" role="alert">{error}</p> : null}
</>
)}
<div className="environment-media-playlist__timing">
<RangeControl
label="Показывать изображение"
value={background.imageDurationSeconds}
min={1}
max={60}
step={1}
disabled={disabled}
formatValue={(value) => `${value} с`}
onChange={(imageDurationSeconds) => onChange({ ...background, imageDurationSeconds })}
/>
<span>Новые элементы появляются сверху. Воспроизведение начинается снизу; перетаскивание меняет порядок.</span>
</div>
</div>
);
}
+324
View File
@@ -0,0 +1,324 @@
import type {
AdapterPackageView,
AdapterVersionView,
BindingView,
ConfigurationRevisionView,
EdgeView,
ModelProfileView,
ProjectGrantView,
DeviceManagerSession,
DeviceManagerPresentation,
DeviceManagerProjectPresentation,
DeviceView,
ProjectSummary,
ProjectWorkspace,
RouteView,
ScopeKind,
} from "./types";
export async function loadPresentation(): Promise<DeviceManagerPresentation> {
return requestJson<{ ok: true; presentation: DeviceManagerPresentation }>(
"/api/device-manager/presentation",
).then((value) => value.presentation);
}
export async function saveProjectPresentation(
projectRef: string,
presentation: DeviceManagerProjectPresentation,
) {
return putJson<{ presentation: DeviceManagerPresentation }>(
"/api/device-manager/presentation/project",
{ projectRef, presentation },
);
}
export async function saveEnvironmentPresentation(
environment: DeviceManagerPresentation["environment"],
) {
return putJson<{ presentation: DeviceManagerPresentation }>(
"/api/device-manager/presentation/environment",
{ environment },
);
}
export async function uploadPresentationMedia(input: {
file: File;
scope: "project" | "environment";
projectRef?: string;
kind: "icon" | "teaser" | "background";
}) {
const params = new URLSearchParams({ scope: input.scope, kind: input.kind });
if (input.projectRef) params.set("projectRef", input.projectRef);
const response = await fetch(`/api/device-manager/presentation/media?${params}`, {
method: "PUT",
credentials: "same-origin",
headers: {
"content-type": input.file.type || "application/octet-stream",
"x-file-name": input.file.name,
},
body: input.file,
});
const body = await response.json().catch(() => null) as { ok?: boolean; fileName?: string; fileSrc?: string; error?: string } | null;
if (!response.ok || body?.ok !== true || !body.fileSrc) {
throw new Error(body?.error || `device_manager_http_${response.status}`);
}
return { fileName: body.fileName || input.file.name, fileSrc: body.fileSrc };
}
export async function loadSession(): Promise<DeviceManagerSession> {
return requestJson<{ ok: true; session: DeviceManagerSession }>(
"/api/device-manager/session",
).then((value) => value.session);
}
export async function loadProjects(): Promise<ProjectSummary[]> {
return requestJson<{ ok: true; projects: ProjectSummary[] }>(
"/api/device-manager/projects",
).then((value) => value.projects);
}
export async function loadWorkspace(projectRef: string): Promise<ProjectWorkspace> {
return requestJson<{ ok: true; workspace: ProjectWorkspace }>(
`/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
).then((value) => value.workspace);
}
export async function ensureOwnerScope(input: {
scopeKind: ScopeKind;
ownerRef: string;
displayName: string;
}) {
return mutate("/api/device-manager/owner-scopes:ensure", input);
}
export async function ensureProject(input: {
scopeKind: ScopeKind;
ownerRef: string;
projectKey: string;
name: string;
description: string | null;
}) {
return mutate("/api/device-manager/projects:ensure", input);
}
export async function ensureCollection(input: {
projectRef: string;
collectionKey: string;
name: string;
description: string | null;
}) {
return mutate("/api/device-manager/collections:ensure", input);
}
export async function claimDevice(input: {
projectRef: string;
enrollmentIntentRef: string;
discoveryRef: string;
deviceKey: string;
displayName: string;
}) {
return mutate("/api/device-manager/devices:claim", input);
}
export async function updateDevice(input: {
projectRef: string;
deviceRef: string;
displayName: string;
integrationDeviceId: string | null;
}) {
return mutate<{ updated: boolean; device: DeviceView }>(
"/api/device-manager/devices:update",
input,
);
}
export async function ensureEnrollmentIntent(input: {
projectRef: string;
enrollmentKey: string;
routeRef: string;
modelProfileRef: string;
displayName: string;
identifier: { kind: "imei"; value: string };
expiresAt: string | null;
}) {
return mutate("/api/device-manager/enrollment-intents:ensure", input);
}
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);
}
export async function sendServicePing(input: {
projectRef: string;
deviceRef: string;
accessCode: string;
expiresInSeconds: number;
}) {
return mutate("/api/device-manager/commands:service-ping", 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",
"Idempotency-Key": `device-manager-${crypto.randomUUID()}`,
},
body: JSON.stringify(input),
});
}
async function putJson<T = unknown>(path: string, input: unknown) {
return requestJson<{ ok: true } & T>(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
credentials: "same-origin",
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
...init,
});
const body = await response.json().catch(() => null);
if (!response.ok || !body?.ok) {
const error = new Error(body?.error || `device_manager_request_failed:${response.status}`);
Object.assign(error, { status: response.status });
throw error;
}
return body as T;
}
@@ -0,0 +1,478 @@
export type DeviceFieldAccess = "read-only" | "managed" | "protected";
export type DeviceFieldValueKind = "text" | "number" | "boolean" | "date";
export interface DeviceProfileField {
key: string;
label: string;
path: string;
access?: DeviceFieldAccess;
valueKind?: DeviceFieldValueKind;
unit?: string;
description?: string;
sensitive?: boolean;
}
export interface DeviceProfileSection {
id: string;
label: string;
title: string;
description: string;
access: DeviceFieldAccess;
fields: DeviceProfileField[];
}
export interface DeviceProfileCatalog {
profileRef: string;
vendor: string;
model: string;
title: string;
sections: DeviceProfileSection[];
}
const field = (
key: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path"> = {},
): DeviceProfileField => ({ key, label, path, ...options });
const managed = (
key: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
) => field(key, label, path, { ...options, access: "managed" });
const protectedField = (
key: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
) => field(key, label, path, { ...options, access: "protected" });
const serverFields = (slot: number) => [
managed(`server-${slot}-host`, `Сервер ${slot}: DNS / IP`, `reported.configuration.monitoring.servers.${slot - 1}.host`),
managed(`server-${slot}-port`, `Сервер ${slot}: порт`, `reported.configuration.monitoring.servers.${slot - 1}.port`, { valueKind: "number" }),
managed(`server-${slot}-protocol`, `Сервер ${slot}: протокол`, `reported.configuration.monitoring.servers.${slot - 1}.protocol`),
managed(`server-${slot}-identity`, `Сервер ${slot}: ID (SN)`, `reported.configuration.monitoring.servers.${slot - 1}.identity`),
managed(`server-${slot}-password`, `Сервер ${slot}: пароль`, `reported.configuration.monitoring.servers.${slot - 1}.password`, { sensitive: true }),
];
const managedIndexedFields = (
count: number,
prefix: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
) => Array.from({ length: count }, (_, index) => managed(
`${prefix}-${index + 1}`,
`${label} ${index + 1}`,
`${path}.${index}`,
options,
));
const phoneFields = Array.from({ length: 5 }, (_, index) => [
managed(`phone-${index + 1}-number`, `Телефон ${index + 1}: номер`, `reported.configuration.phones.${index}.number`, { sensitive: true }),
managed(`phone-${index + 1}-mode`, `Телефон ${index + 1}: режим`, `reported.configuration.phones.${index}.mode`),
]).flat();
const simFields = (slot: number) => [
managed(`sim-${slot}-gprs`, `SIM ${slot}: передача данных`, `reported.configuration.simCards.${slot - 1}.gprsEnabled`, { valueKind: "boolean" }),
managed(`sim-${slot}-apn`, `SIM ${slot}: APN оператора`, `reported.configuration.simCards.${slot - 1}.apn`),
managed(`sim-${slot}-login`, `SIM ${slot}: логин APN`, `reported.configuration.simCards.${slot - 1}.login`, { sensitive: true }),
managed(`sim-${slot}-password`, `SIM ${slot}: пароль APN`, `reported.configuration.simCards.${slot - 1}.password`, { sensitive: true }),
managed(`sim-${slot}-roaming`, `SIM ${slot}: роуминг`, `reported.configuration.simCards.${slot - 1}.roamingEnabled`, { valueKind: "boolean" }),
managed(`sim-${slot}-operator`, `SIM ${slot}: приоритетный оператор`, `reported.configuration.simCards.${slot - 1}.preferredOperatorCode`),
managed(`sim-${slot}-pin`, `SIM ${slot}: PIN`, `reported.configuration.simCards.${slot - 1}.pin`, { sensitive: true }),
managed(`sim-${slot}-ussd`, `SIM ${slot}: USSD запроса баланса`, `reported.configuration.simCards.${slot - 1}.balanceUssd`, { sensitive: true }),
managed(`sim-${slot}-poll`, `SIM ${slot}: период запроса баланса`, `reported.configuration.simCards.${slot - 1}.balancePollHours`, { valueKind: "number", unit: "ч" }),
];
const motionEventFields = ["acceleration", "braking", "cornering", "vertical"].flatMap((event) => {
const labels: Record<string, string> = {
acceleration: "Разгон",
braking: "Торможение",
cornering: "Угловое ускорение",
vertical: "Вертикальное ускорение",
};
return Array.from({ length: 3 }, (_, level) => [
managed(`${event}-${level + 1}-threshold`, `${labels[event]} ${level + 1}: порог`, `reported.configuration.drivingStyle.${event}.${level}.thresholdMg`, { valueKind: "number", unit: "mg" }),
managed(`${event}-${level + 1}-duration`, `${labels[event]} ${level + 1}: длительность превышения`, `reported.configuration.drivingStyle.${event}.${level}.durationMs`, { valueKind: "number", unit: "мс" }),
managed(`${event}-${level + 1}-reset`, `${labels[event]} ${level + 1}: задержка сброса`, `reported.configuration.drivingStyle.${event}.${level}.resetDelayMs`, { valueKind: "number", unit: "мс" }),
]).flat();
}).flat();
const violationFields = ["speed", "rpm"].flatMap((kind) => Array.from({ length: 4 }, (_, level) => [
managed(`${kind}-${level + 1}-threshold`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог`, `reported.configuration.drivingStyle.violations.${kind}.${level}.threshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
managed(`${kind}-${level + 1}-duration`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: минимальное время`, `reported.configuration.drivingStyle.violations.${kind}.${level}.minimumDurationSeconds`, { valueKind: "number", unit: "с" }),
managed(`${kind}-${level + 1}-reset`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог сброса`, `reported.configuration.drivingStyle.violations.${kind}.${level}.resetThreshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
]).flat()).flat();
const modbusRegisterFields = Array.from({ length: 10 }, (_, index) => [
managed(`modbus-register-${index + 1}`, `Регистр ${index + 1}: номер`, `reported.configuration.modbus.registers.${index}.number`, { valueKind: "number" }),
managed(`modbus-register-${index + 1}-pair`, `Регистр ${index + 1}: читать два регистра`, `reported.configuration.modbus.registers.${index}.readPair`, { valueKind: "boolean" }),
]).flat();
const bleFields = Array.from({ length: 10 }, (_, index) => [
managed(`ble-${index + 1}-mac`, `BLE датчик ${index + 1}: MAC`, `reported.configuration.bluetooth.sensors.${index}.mac`),
managed(`ble-${index + 1}-integration`, `BLE датчик ${index + 1}: интеграция`, `reported.configuration.bluetooth.sensors.${index}.integrationExpression`),
]).flat();
export const ARUSNAVI_B2_CATALOG: DeviceProfileCatalog = {
profileRef: "arusnavi.b2.internal.v1",
vendor: "ARUSNAVI",
model: "B2",
title: "ARUSNAVI B2",
sections: [
{
id: "passport",
label: "Паспорт",
title: "Паспорт и состояние устройства",
description: "Реестровая идентичность, профиль модели и текущее состояние канала. Исходный идентификатор показывается только в проекции, разрешённой Device Core.",
access: "read-only",
fields: [
managed(
"device-display-name",
"Название устройства",
"device.displayName",
{ description: "Произвольное имя, которое Device Core показывает в реестре и карточке устройства." },
),
managed(
"integration-device-id",
"ID интеграционного устройства",
"device.integrationDeviceId",
{ description: "Идентификатор целевого актива во внешней бизнес-системе, например ID трайка." },
),
field("device-key", "Ключ устройства", "device.deviceKey"),
field("device-ref", "Device Core ref", "device.deviceRef"),
field("vendor", "Производитель", "profile.vendor"),
field("model", "Модель", "profile.model"),
field("device-type", "Тип", "profile.deviceType"),
field("profile", "Профиль модели", "device.modelProfileRef"),
field("identifier-kind", "Тип идентификатора", "device.identifier.kind"),
field("imei", "IMEI", "device.identifier.displayValue", {
description: "Полное значение отображается только в разрешённой Device Core проекции; иначе показывается защищённая маска.",
}),
field("iccid-1", "ICCID 1", "reported.identity.iccid1"),
field("iccid-2", "ICCID 2", "reported.identity.iccid2"),
field("lifecycle", "Состояние реестра", "device.lifecycleState"),
field("created", "Зарегистрирован", "device.createdAt", { valueKind: "date" }),
field("updated", "Обновлён", "device.updatedAt", { valueKind: "date" }),
field("reported-at", "Снимок устройства получен", "reported.observedAt", { valueKind: "date" }),
managed("asset-model", "Модель актива", "reported.metadata.model"),
managed("registration", "Регистрационный номер", "reported.metadata.registrationNumber"),
managed("object", "Объект", "reported.metadata.object"),
managed("description", "Описание", "reported.metadata.description"),
managed("sim-label-1", "Метка SIM 1", "reported.metadata.simLabel1"),
managed("sim-label-2", "Метка SIM 2", "reported.metadata.simLabel2"),
],
},
{
id: "live",
label: "Онлайн",
title: "Живой канал и телеметрия",
description: "Значения обновляются из последней gateway-сессии и безопасного снимка телеметрии. Интерфейс опрашивает Device Core, пока открыта карточка.",
access: "read-only",
fields: [
field("session-state", "Состояние соединения", "session.lifecycleState"),
field("session-route", "Маршрут", "session.routeName"),
field("session-connected", "Подключён", "session.connectedAt", { valueKind: "date" }),
field("session-last-seen", "Последний пакет", "session.lastSeenAt", { valueKind: "date" }),
field("session-frames", "Принято пакетов", "session.frameCount", { valueKind: "number" }),
field("session-bytes", "Принято данных", "session.byteCount", { valueKind: "number", unit: "байт" }),
field("latitude", "Широта", "reported.telemetry.navigation.latitude"),
field("longitude", "Долгота", "reported.telemetry.navigation.longitude"),
field("speed", "Скорость", "reported.telemetry.navigation.speedKph", { valueKind: "number", unit: "км/ч" }),
field("altitude", "Высота", "reported.telemetry.navigation.altitudeMeters", { valueKind: "number", unit: "м" }),
field("satellites", "Спутники", "reported.telemetry.navigation.satellites", { valueKind: "number" }),
field("course", "Курс", "reported.telemetry.navigation.courseDegrees", { valueKind: "number", unit: "°" }),
field("hdop", "HDOP", "reported.telemetry.navigation.hdop"),
field("gsm-signal", "Уровень GSM", "reported.telemetry.gsm.signal"),
field("gsm-operator", "Оператор", "reported.telemetry.gsm.operator"),
field("gsm-lac", "LAC", "reported.telemetry.gsm.lac"),
field("gsm-cid", "CID", "reported.telemetry.gsm.cid"),
field("external-voltage", "Внешнее напряжение", "reported.telemetry.system.externalVoltageMv", { valueKind: "number", unit: "мВ" }),
field("internal-voltage", "Внутреннее напряжение", "reported.telemetry.system.internalVoltageMv", { valueKind: "number", unit: "мВ" }),
field("errors", "Ошибки и статусы", "reported.telemetry.system.status"),
field("inputs", "Входы и выходы", "reported.telemetry.system.io"),
field("modules", "Статусы модулей", "reported.telemetry.system.modules"),
field("engine-hours", "Моточасы", "reported.telemetry.can.engineHours"),
field("odometer", "Пробег", "reported.telemetry.can.odometer"),
field("fuel-total", "Полный расход топлива", "reported.telemetry.can.fuelTotal"),
field("fuel-level", "Уровень топлива", "reported.telemetry.can.fuelLevel"),
field("rpm", "Обороты двигателя", "reported.telemetry.can.rpm"),
field("engine-temp", "Температура двигателя", "reported.telemetry.can.engineTemperature"),
field("vehicle-speed", "Скорость по CAN", "reported.telemetry.can.vehicleSpeed"),
field("axle-pressure", "Давление на оси", "reported.telemetry.can.axlePressure"),
field("crash", "Контроллер аварии", "reported.telemetry.can.crashController"),
field("instant-fuel", "Моментальный расход", "reported.telemetry.can.instantFuel"),
field("adblue", "Уровень AdBlue", "reported.telemetry.can.adBlueLevel"),
],
},
{
id: "firmware",
label: "Прошивка",
title: "Версия программного обеспечения",
description: "Версию и доступность обновления показываем, но запуск обновления для пилотного B2 запрещён. Этот запрет не снимается включением обычного командного канала.",
access: "protected",
fields: [
field("firmware-current", "Текущая версия", "reported.firmware.currentVersion"),
field("firmware-applied", "Версия применена", "reported.firmware.appliedAt", { valueKind: "date" }),
field("firmware-available", "Доступная версия", "reported.firmware.availableVersion"),
field("firmware-description", "Описание версии", "reported.firmware.description"),
protectedField("firmware-action", "Обновление прошивки", "policies.firmwareUpdate", { description: "Заблокировано для пилотного устройства" }),
],
},
{
id: "templates",
label: "Шаблоны",
title: "Шаблоны настроек",
description: "Шаблон хранит именованный снимок конфигурации модели. Применение должно создавать новую desired-ревизию, а не менять устройство в обход command ledger.",
access: "managed",
fields: [
field("template-current", "Применённый шаблон", "reported.configurationTemplate.name"),
field("template-applied", "Шаблон применён", "reported.configurationTemplate.appliedAt", { valueKind: "date" }),
managed("template-select", "Выбранный шаблон", "reported.configurationTemplate.selected"),
managed("template-name", "Название нового шаблона", "reported.configurationTemplate.draft.name"),
managed("template-description", "Описание нового шаблона", "reported.configurationTemplate.draft.description"),
],
},
{
id: "monitoring",
label: "Серверы",
title: "Серверы мониторинга",
description: "B2 поддерживает четыре серверных слота. Существующий Gelios сохраняется параллельно; новый маршрут не должен его перетирать.",
access: "managed",
fields: [1, 2, 3, 4].flatMap(serverFields),
},
{
id: "transmission",
label: "Передача",
title: "Набор передаваемых данных",
description: "Флаги определяют состав телеметрии, которую формирует устройство.",
access: "managed",
fields: [
managed("tx-nav-position", "Навигация: широта и долгота", "reported.configuration.transmission.navigation.position", { valueKind: "boolean" }),
managed("tx-nav-motion", "Навигация: скорость, высота, спутники и курс", "reported.configuration.transmission.navigation.motion", { valueKind: "boolean" }),
managed("tx-nav-hdop", "Навигация: HDOP", "reported.configuration.transmission.navigation.hdop", { valueKind: "boolean" }),
managed("tx-gsm-operator", "GSM: сигнал и оператор", "reported.configuration.transmission.gsm.operator", { valueKind: "boolean" }),
managed("tx-gsm-cell", "GSM: LAC и CID", "reported.configuration.transmission.gsm.cell", { valueKind: "boolean" }),
managed("tx-system-status", "Системные: ошибки и статусы", "reported.configuration.transmission.system.status", { valueKind: "boolean" }),
managed("tx-system-io", "Системные: входы, выходы и модули", "reported.configuration.transmission.system.io", { valueKind: "boolean" }),
managed("tx-system-voltage", "Системные: напряжения", "reported.configuration.transmission.system.voltage", { valueKind: "boolean" }),
...["statuses", "engineHours", "odometer", "fuelTotal", "fuelLevel", "rpm", "engineTemperature", "vehicleSpeed", "axlePressure", "crashController", "instantFuel", "adBlueLevel"].map((key) => managed(`tx-can-${key}`, `CAN: ${({ statuses: "статусы работы", engineHours: "моточасы", odometer: "пробег", fuelTotal: "полный расход топлива", fuelLevel: "уровень топлива", rpm: "обороты двигателя", engineTemperature: "температура двигателя", vehicleSpeed: "скорость", axlePressure: "давление на оси", crashController: "контроллер аварии", instantFuel: "моментальный расход", adBlueLevel: "уровень AdBlue" } as Record<string, string>)[key]}`, `reported.configuration.transmission.can.${key}`, { valueKind: "boolean" })),
],
},
{
id: "trajectory",
label: "Траектория",
title: "Отрисовка траектории и датчик движения",
description: "Обычные и роуминговые интервалы, заморозка координат и параметры встроенного датчика движения.",
access: "managed",
fields: [
...["normal", "roaming"].flatMap((mode) => {
const label = mode === "normal" ? "Основной режим" : "Роуминг";
return [
managed(`${mode}-course`, `${label}: изменение курса`, `reported.configuration.trajectory.${mode}.courseDeltaDegrees`, { valueKind: "number", unit: "°" }),
managed(`${mode}-speed`, `${label}: изменение скорости`, `reported.configuration.trajectory.${mode}.speedDeltaKph`, { valueKind: "number", unit: "км/ч" }),
managed(`${mode}-distance`, `${label}: расстояние между точками`, `reported.configuration.trajectory.${mode}.distanceMeters`, { valueKind: "number", unit: "м" }),
managed(`${mode}-parking`, `${label}: интервал на стоянке`, `reported.configuration.trajectory.${mode}.parkingIntervalSeconds`, { valueKind: "number", unit: "с" }),
];
}),
managed("freeze-low-speed", "Заморозка координат при скорости ниже 2 км/ч", "reported.configuration.trajectory.freeze.lowSpeed", { valueKind: "boolean" }),
managed("freeze-motion", "Заморозка по датчику движения", "reported.configuration.trajectory.freeze.motionSensor", { valueKind: "boolean" }),
managed("freeze-ignition", "Заморозка по зажиганию", "reported.configuration.trajectory.freeze.ignition", { valueKind: "boolean" }),
managed("freeze-quiet", "Тихоходная техника", "reported.configuration.trajectory.freeze.lowSpeedVehicle", { valueKind: "boolean" }),
managed("motion-sensitivity", "Чувствительность датчика движения", "reported.configuration.motionSensor.sensitivity", { valueKind: "number" }),
managed("motion-delay", "Задержка срабатывания", "reported.configuration.motionSensor.delaySeconds", { valueKind: "number", unit: "с" }),
managed("motion-impact", "Порог удара", "reported.configuration.motionSensor.impact", { valueKind: "number" }),
managed("motion-tilt", "Порог наклона", "reported.configuration.motionSensor.tilt", { valueKind: "number" }),
],
},
{
id: "io",
label: "Входы / выходы",
title: "Входы и выходы",
description: "Режимы PIN0–PIN7 и пороги. Непосредственное переключение выходов относится к защищённым командам.",
access: "managed",
fields: [
...managedIndexedFields(8, "pin-mode", "Режим PIN", "reported.configuration.io.pinModes"),
managed("speed-coefficient", "Коэффициент датчика скорости", "reported.configuration.io.speedSensorCoefficient", { valueKind: "number" }),
managed("virtual-ignition", "Порог виртуального зажигания", "reported.configuration.io.virtualIgnitionThresholdMv", { valueKind: "number", unit: "мВ" }),
managed("analog-pin-2", "Порог аналогового входа PIN2", "reported.configuration.io.analogThresholds.pin2Mv", { valueKind: "number", unit: "мВ" }),
managed("analog-pin-3", "Порог аналогового входа PIN3", "reported.configuration.io.analogThresholds.pin3Mv", { valueKind: "number", unit: "мВ" }),
protectedField("output-4", "Команда выхода PIN4", "reported.operations.outputs.pin4"),
protectedField("output-5", "Команда выхода PIN5", "reported.operations.outputs.pin5"),
protectedField("output-6", "Команда выхода PIN6", "reported.operations.outputs.pin6"),
],
},
{
id: "ports",
label: "Порты",
title: "Цифровые порты и датчики",
description: "RS232, RS485, CAN, WiFi, фотоснимки, 1‑Wire и фильтрация датчиков.",
access: "managed",
fields: [
managed("rs232", "RS232", "reported.configuration.ports.rs232.mode"),
managed("rs485", "RS485", "reported.configuration.ports.rs485.mode"),
managed("can-program", "Номер программы CAN", "reported.configuration.ports.can.program", { valueKind: "number" }),
managed("can-internal", "Активировать внутренний CAN", "reported.configuration.ports.can.internalEnabled", { valueKind: "boolean" }),
managed("can-seatbelt", "Контролировать ремень по CAN", "reported.configuration.ports.can.seatbelt", { valueKind: "boolean" }),
managed("can-headlight", "Контролировать ближний свет по CAN", "reported.configuration.ports.can.headlight", { valueKind: "boolean" }),
managed("wifi-ssid", "WiFi: имя сети", "reported.configuration.ports.wifi.ssid"),
managed("wifi-password", "WiFi: пароль", "reported.configuration.ports.wifi.password", { sensitive: true }),
managed("photo-interval", "Интервал фотоснимков", "reported.configuration.ports.camera.intervalMinutes", { valueKind: "number", unit: "мин" }),
managed("photo-resolution", "Разрешение фотоснимков", "reported.configuration.ports.camera.resolution"),
managed("one-wire-auto", "Сохранять новые термодатчики", "reported.configuration.ports.oneWire.autoDiscover", { valueKind: "boolean" }),
...managedIndexedFields(10, "one-wire", "Адрес термодатчика", "reported.configuration.ports.oneWire.sensorAddresses"),
managed("median-filter", "Медианный фильтр датчиков", "reported.configuration.ports.sensorFilter.medianEnabled", { valueKind: "boolean" }),
...managedIndexedFields(4, "lls-filter", "Степень фильтрации LLS", "reported.configuration.ports.sensorFilter.lls", { valueKind: "number" }),
],
},
{
id: "modbus",
label: "Modbus",
title: "Параметры Modbus",
description: "Последовательный порт, сетевые адреса и до десяти читаемых регистров.",
access: "managed",
fields: [
managed("modbus-baud", "Скорость обмена", "reported.configuration.modbus.baudRate", { valueKind: "number" }),
managed("modbus-poll", "Таймер опроса", "reported.configuration.modbus.pollSeconds", { valueKind: "number", unit: "с" }),
managed("modbus-parity", "Проверка на чётность", "reported.configuration.modbus.parity"),
managed("modbus-stop", "Stop bits", "reported.configuration.modbus.stopBits"),
managed("modbus-address-a", "Сетевой адрес датчика для регистров 1–5", "reported.configuration.modbus.addresses.first", { valueKind: "number" }),
managed("modbus-address-b", "Сетевой адрес датчика для регистров 6–10", "reported.configuration.modbus.addresses.second", { valueKind: "number" }),
...modbusRegisterFields,
],
},
{
id: "bluetooth",
label: "Bluetooth",
title: "Bluetooth (BLE) датчики",
description: "Режим BLE-модуля, код сопряжения и десять датчиков с выражениями универсальной интеграции.",
access: "managed",
fields: [
managed("ble-mode", "Режим работы Bluetooth", "reported.configuration.bluetooth.mode"),
managed("ble-pairing", "Код сопряжения", "reported.configuration.bluetooth.pairingCode", { sensitive: true }),
...bleFields,
],
},
{
id: "driving-style",
label: "Стиль вождения",
title: "Стиль вождения",
description: "Пороговые профили акселерометра и превышений скорости/оборотов.",
access: "managed",
fields: [
...motionEventFields,
managed("accelerometer-transmit", "Передавать данные акселерометра", "reported.configuration.drivingStyle.transmitAccelerometer", { valueKind: "boolean" }),
managed("accelerometer-reset-events", "Передавать события сброса", "reported.configuration.drivingStyle.transmitResetEvents", { valueKind: "boolean" }),
managed("accelerometer-bitmask", "Передавать состояния сработок", "reported.configuration.drivingStyle.transmitTriggerMask", { valueKind: "boolean" }),
managed("accelerometer-average", "Глубина усреднения акселерометра", "reported.configuration.drivingStyle.averagingDepth", { valueKind: "number" }),
...violationFields,
],
},
{
id: "phones",
label: "Телефоны",
title: "Разрешённые телефоны",
description: "До пяти номеров и индивидуальный режим доступа для SMS-управления.",
access: "managed",
fields: phoneFields,
},
{
id: "sim",
label: "SIM-карты",
title: "SIM-карты и мобильная сеть",
description: "Параметры двух SIM-профилей. Пароли, PIN и USSD не возвращаются в открытом виде.",
access: "managed",
fields: [...simFields(1), ...simFields(2)],
},
{
id: "navigation",
label: "Навигация",
title: "Навигация и фильтрация координат",
description: "Источники координат, спутниковые группировки, внешний локатор и фильтры качества.",
access: "managed",
fields: [
managed("nav-satellite", "Спутниковая навигация", "reported.configuration.navigation.sources.satellite", { valueKind: "boolean" }),
managed("nav-wifi", "WiFi локатор", "reported.configuration.navigation.sources.wifi", { valueKind: "boolean" }),
managed("nav-lbs", "LBS локатор", "reported.configuration.navigation.sources.lbs", { valueKind: "boolean" }),
managed("nav-tag", "Навигационная метка", "reported.configuration.navigation.sources.tag", { valueKind: "boolean" }),
...["gps", "glonass", "galileo", "beidou"].map((key) => managed(`nav-${key}`, key.toUpperCase(), `reported.configuration.navigation.constellations.${key}`, { valueKind: "boolean" })),
managed("locator-url", "URL локатора", "reported.configuration.navigation.locator.url", { sensitive: true }),
managed("locator-moving", "Интервал локатора в движении", "reported.configuration.navigation.locator.movingIntervalSeconds", { valueKind: "number", unit: "с" }),
managed("locator-parked", "Интервал локатора на стоянке", "reported.configuration.navigation.locator.parkedIntervalSeconds", { valueKind: "number", unit: "с" }),
managed("filter-satellites", "Минимальное число спутников", "reported.configuration.navigation.filter.minimumSatellites", { valueKind: "number" }),
managed("filter-hdop", "Максимальный HDOP × 10", "reported.configuration.navigation.filter.maximumHdopTimesTen", { valueKind: "number" }),
managed("filter-altitude-min", "Минимальная высота", "reported.configuration.navigation.filter.minimumAltitudeMeters", { valueKind: "number", unit: "м" }),
managed("filter-altitude-max", "Максимальная высота", "reported.configuration.navigation.filter.maximumAltitudeMeters", { valueKind: "number", unit: "м" }),
managed("filter-speed-min", "Минимальная мгновенная скорость", "reported.configuration.navigation.filter.minimumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
managed("filter-speed-max", "Максимальная мгновенная скорость", "reported.configuration.navigation.filter.maximumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
managed("filter-speed-average", "Максимальная средняя скорость", "reported.configuration.navigation.filter.maximumAverageSpeedKph", { valueKind: "number", unit: "км/ч" }),
managed("filter-time", "Максимальное время фильтрации", "reported.configuration.navigation.filter.maximumSeconds", { valueKind: "number", unit: "с" }),
],
},
{
id: "system",
label: "Системные",
title: "Системные параметры",
description: "Системные интервалы и энергосбережение. Секретные значения отображаются только как факт наличия.",
access: "managed",
fields: [
managed("sms-password", "Пароль устройства (SMS)", "reported.configuration.system.smsPassword", { sensitive: true }),
managed("web-check-hours", "Проверять WEB-конфигуратор каждые", "reported.configuration.system.webConfiguration.checkHours", { valueKind: "number", unit: "ч" }),
managed("web-check-start", "Проверять WEB-конфигуратор при старте", "reported.configuration.system.webConfiguration.onStart", { valueKind: "boolean" }),
managed("sleep-mode", "Режим сна", "reported.configuration.system.powerSaving.mode"),
managed("sleep-wake-interval", "Выходить на связь каждые", "reported.configuration.system.powerSaving.wakeIntervalMinutes", { valueKind: "number", unit: "мин" }),
managed("sleep-online", "Время пребывания на связи", "reported.configuration.system.powerSaving.onlineMinutes", { valueKind: "number", unit: "мин" }),
managed("sleep-motion", "Выходить из сна по датчику движения", "reported.configuration.system.powerSaving.wakeOnMotion", { valueKind: "boolean" }),
managed("sleep-input", "Выходить из сна по изменению входа", "reported.configuration.system.powerSaving.wakeOnInput", { valueKind: "boolean" }),
managed("battery-ignition", "Заряжать АКБ только при включённом зажигании", "reported.configuration.system.chargeBatteryOnIgnitionOnly", { valueKind: "boolean" }),
],
},
{
id: "diagnostics",
label: "Диагностика",
title: "Диагностика и операции",
description: "Доступные B2 операции отражены полностью, но выполняются только через подтверждённый двусторонний канал и отдельный command ledger.",
access: "protected",
fields: [
field("debug-last-session", "Последняя удалённая отладка", "reported.diagnostics.lastSessionAt", { valueKind: "date" }),
field("debug-output", "Результат удалённой отладки", "reported.diagnostics.output"),
protectedField("op-packet", "Запросить пакет телеметрии", "reported.operations.requestTelemetry"),
protectedField("op-info", "Запросить информацию", "reported.operations.requestInfo"),
protectedField("op-coordinates", "Запросить координаты", "reported.operations.requestCoordinates"),
protectedField("op-config", "Синхронизировать настройки", "reported.operations.syncConfiguration"),
protectedField("op-restart", "Перезапустить устройство", "reported.operations.restart"),
protectedField("op-clear", "Очистить память", "reported.operations.clearMemory"),
protectedField("op-firmware", "Обновить прошивку", "reported.operations.updateFirmware", { description: "Запрещено для пилотного B2" }),
],
},
],
};
const genericCatalog: DeviceProfileCatalog = {
profileRef: "generic.device.v1",
vendor: "NODE.DC",
model: "Generic device",
title: "Устройство",
sections: ARUSNAVI_B2_CATALOG.sections.filter((section) => ["passport", "live"].includes(section.id)),
};
const catalogs = new Map<string, DeviceProfileCatalog>([
[ARUSNAVI_B2_CATALOG.profileRef, ARUSNAVI_B2_CATALOG],
]);
export function getDeviceProfileCatalog(profileRef: string): DeviceProfileCatalog {
return catalogs.get(profileRef) ?? { ...genericCatalog, profileRef };
}
export function accessLabel(access: DeviceFieldAccess) {
return ({
"read-only": "Только чтение",
managed: "Управляемая настройка",
protected: "Защищённая операция",
})[access];
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "@nodedc/ui-core/styles.css";
import "./styles.css";
import { DeviceManagerApp } from "./DeviceManagerApp";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<DeviceManagerApp />
</StrictMode>,
);
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
export type HubRole = "viewer" | "member" | "admin" | "owner";
export type ScopeKind = "company" | "personal";
export interface OwnerScopeClaim {
scopeKind: ScopeKind;
ownerRef: string;
displayName: string;
}
export interface DeviceManagerSession {
user: {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
initials: string;
};
actor: {
userRef: string;
hubRole: HubRole;
groupRefs: string[];
ownerScopes: OwnerScopeClaim[];
};
profileUrl: string;
}
export type DeviceManagerTheme = "dark" | "light";
export type DeviceManagerMediaSource = "file" | "url";
export type DeviceManagerMediaKind = "image" | "video";
export interface DeviceManagerMediaValue {
source: DeviceManagerMediaSource;
url: string;
fileName: string | null;
fileSrc: string | null;
}
export interface DeviceManagerEnvironmentMediaItem extends DeviceManagerMediaValue {
id: string;
mediaKind: DeviceManagerMediaKind | null;
}
export interface DeviceManagerEnvironmentOverview {
headerLabel: string;
eyebrow: string;
title: string;
description: string;
primarySection: string | null;
secondarySection: string | null;
background: {
enabled: boolean;
imageDurationSeconds: number;
items: DeviceManagerEnvironmentMediaItem[];
};
}
export interface DeviceManagerProjectPresentation {
icon: DeviceManagerMediaValue;
teaser: DeviceManagerMediaValue;
}
export interface DeviceManagerPresentation {
environment: {
theme: DeviceManagerTheme;
accentHex: string;
overview: DeviceManagerEnvironmentOverview;
};
projects: Record<string, DeviceManagerProjectPresentation>;
}
export interface ProjectSummary {
projectRef: string;
projectKey: string;
name: string;
description: string | null;
lifecycleState: string;
ownerScope: {
ownerScopeRef: string;
scopeKind: ScopeKind;
ownerRef: string;
displayName: string;
};
access: {
projectRole: string | null;
capabilities: string[];
};
counts: { devices: number; collections: number; discoveries: number };
createdAt: string | null;
updatedAt: string | null;
}
export interface DeviceView {
deviceRef: string;
deviceKey: string | null;
displayName: string;
integrationDeviceId: string | null;
modelProfileRef: string;
lifecycleState: string;
identifier: { kind: string; masked: string; value?: string } | null;
session: { state: string; lastSeenAt: string | null } | null;
reported?: {
observedAt?: string | null;
identity?: Record<string, unknown>;
metadata?: Record<string, unknown>;
firmware?: Record<string, unknown>;
configuration?: Record<string, unknown>;
telemetry?: Record<string, unknown>;
diagnostics?: Record<string, unknown>;
operations?: Record<string, unknown>;
} | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface CollectionView {
collectionRef: string;
collectionKey: string;
name: string;
description: string | null;
lifecycleState: string;
memberCount: number;
createdAt: string | null;
updatedAt: string | null;
}
export interface DiscoveryView {
discoveryRef: string;
identifier: { kind: string; masked: string };
modelProfileRef: string;
protocol: string;
lifecycleState: string;
enrollmentIntentRef: string | null;
claimedDeviceRef: string | null;
firstObservedAt: string | null;
lastObservedAt: string | null;
}
export interface EnrollmentView {
enrollmentIntentRef: string;
enrollmentKey: string;
displayName: string;
modelProfileRef: string;
expectedIdentifier: { kind: string; masked: string };
lifecycleState: string;
observedDiscoveryRef: string | null;
claimedDeviceRef: string | null;
expiresAt: string | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface AdapterPackageView {
adapterPackageRef: string;
packageKey: string;
displayName: string;
publisherRef: string;
lifecycleState: string;
createdAt: string | null;
updatedAt: string | null;
}
export interface AdapterVersionView {
adapterVersionRef: string;
adapterPackageRef: string;
version: string;
runtimePackageRef: string;
contentDigest: string;
contractVersion: string;
capabilities: string[];
lifecycleState: string;
createdAt: string | null;
updatedAt: string | null;
}
export interface ModelProfileView {
modelProfileRef: string;
adapterVersionRef: string | null;
schemaVersion: string;
vendor: string;
model: string;
deviceType: string;
protocol: string;
schemaArtifactRef: string | null;
profileDigest: string | null;
capabilities: string[];
lifecycleState: string;
createdAt: string | null;
updatedAt: string | null;
}
export interface EdgeView {
edgeRef: string;
edgeKey: string;
displayName: string;
deploymentRef: string | null;
lifecycleState: string;
createdAt: string | null;
updatedAt: string | null;
}
export interface RouteView {
routeRef: string;
routeKey: string;
displayName: string;
edgeRef: string;
edgeName: string;
modelProfileRef: string;
profileName: string;
listenerRef: string;
protocol: string;
direction: "telemetry" | "bidirectional";
lifecycleState: "draft" | "active" | "suspended" | "retired";
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" | "typed-service-ping-v1";
commandPlanningApi: "disabled" | "enabled";
identifierProjection: string;
auditPayloadProjection: string;
};
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: { sourcemap: true },
});