refactor: move Device Manager app to Device Core repository

This commit is contained in:
Codex
2026-08-21 12:23:21 +03:00
parent 8f38c76f79
commit 51cb426c6b
22 changed files with 0 additions and 6443 deletions
-60
View File
@@ -1,60 +0,0 @@
# 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
@@ -1,13 +0,0 @@
<!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>
-26
View File
@@ -1,26 +0,0 @@
{
"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/ui-core": "0.7.0",
"@nodedc/ui-react": "0.7.0",
"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"
}
}
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 315 B

@@ -1,889 +0,0 @@
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"],
["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: "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);
}
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: "Локальная визуальная фикстура без реальных идентификаторов и команд",
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",
modelProfileRef: "arusnavi.b2.internal.v1",
lifecycleState: "active",
identifier: { kind: "imei", masked: "***********0001" },
session: { state: "online", lastSeenAt: timestamp },
reported: {
observedAt: timestamp,
identity: { 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;
}
@@ -1,246 +0,0 @@
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, "***********0001");
assert.equal(workspace.sessions[0].lifecycleState, "online");
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
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" },
});
}
@@ -1,465 +0,0 @@
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;
}
@@ -1,214 +0,0 @@
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" },
});
}
@@ -1,275 +0,0 @@
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";
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/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"),
} = {}) {
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 });
}
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,
});
}
return createDeviceManagerServer({ auth, coreClient });
}
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" });
}
response.statusCode = 200;
response.setHeader("Content-Type", contentType(filePath));
response.setHeader(
"Cache-Control",
filePath.endsWith("index.html") ? "no-store" : "private, max-age=300",
);
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) {
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);
}
try {
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
return body;
} catch {
throw serviceError("device_manager_json_invalid", 400);
}
}
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 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",
".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,
}));
});
}
@@ -1,178 +0,0 @@
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;
}
@@ -1,955 +0,0 @@
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>
<div className="device-control-command-policy">
<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>
</div>
{enabled ? (
<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>
) : 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) => <span key={item}>{item}</span>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
</SettingsCard>;
}
function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
return hasChildren ? <div className="device-manager-entity-list">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
}
function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
return <GlassSurface className="device-manager-entity device-control-row" padding="md" tone="soft">
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
<span className="device-manager-entity__body"><strong>{title}</strong><small>{description}</small></span>
<span className="device-control-row__status"><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{typeof trailing === "string" ? <small>{trailing}</small> : trailing}</span>
</GlassSurface>;
}
function PolicyCard({ label, value }: { label: string; value: string }) {
return <GlassSurface padding="md" tone="soft"><small>{label}</small><strong>{value}</strong></GlassSurface>;
}
function commaList(value: string) {
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort();
}
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
return "neutral";
}
function formatDate(value: string | null) {
if (!value) return "—";
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
function shortRef(value: string | null) {
return value ? `${value.slice(0, 18)}` : "—";
}
function shortDigest(value: string) {
return `${value.slice(0, 15)}${value.slice(-8)}`;
}
@@ -1,329 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
GlassSurface,
Icon,
IconButton,
StatusBadge,
} from "@nodedc/ui-react";
import {
accessLabel,
getDeviceProfileCatalog,
type DeviceFieldAccess,
type DeviceProfileField,
} from "./deviceProfileCatalog";
import type {
DeviceView,
ProjectWorkspace,
SessionView,
} from "./types";
export function DeviceInventoryView({
workspace,
canEnroll,
onCreateEnrollment,
onPoll,
onError,
}: {
workspace: ProjectWorkspace;
canEnroll: boolean;
onCreateEnrollment: () => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [selectedDeviceRef, setSelectedDeviceRef] = useState<string | null>(null);
const selectedDevice = workspace.devices.find(
(device) => device.deviceRef === selectedDeviceRef,
) ?? null;
const activeRouteAvailable = workspace.routes.some(
(route) => route.lifecycleState === "active",
);
useEffect(() => {
setSelectedDeviceRef(null);
}, [workspace.project.projectRef]);
useEffect(() => {
if (selectedDeviceRef && !selectedDevice) setSelectedDeviceRef(null);
}, [selectedDevice, selectedDeviceRef]);
useEffect(() => {
if (!selectedDeviceRef) return undefined;
const poll = () => {
if (document.visibilityState === "visible") onPoll().catch(onError);
};
const timer = window.setInterval(poll, 5_000);
return () => window.clearInterval(timer);
}, [onError, onPoll, selectedDeviceRef]);
if (selectedDevice) {
return (
<DeviceDetailView
device={selectedDevice}
workspace={workspace}
onBack={() => setSelectedDeviceRef(null)}
/>
);
}
return (
<div className="device-inventory">
<div className="device-manager-panel-toolbar device-inventory__toolbar">
<div>
<strong>Реестр устройств</strong>
<p>{workspace.devices.length} зарегистрировано в проекте</p>
</div>
<IconButton
label="Подключить устройство"
disabled={!canEnroll || !activeRouteAvailable}
onClick={onCreateEnrollment}
>
<Icon name="plus" size={18} />
</IconButton>
</div>
{!workspace.devices.length ? (
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
<Icon name="inbox" size={24} />
<h3>В проекте пока нет устройств</h3>
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому enrollment-процессу.</p>
</GlassSurface>
) : (
<div className="device-inventory-table" role="table" aria-label="Устройства проекта">
<div className="device-inventory-table__head" role="row">
<span role="columnheader">Устройство</span>
<span role="columnheader">Профиль</span>
<span role="columnheader">Идентификатор</span>
<span role="columnheader">Канал</span>
<span role="columnheader">Последний пакет</span>
<span aria-hidden="true" />
</div>
{workspace.devices.map((device) => {
const session = latestSession(workspace, device);
const online = session?.lifecycleState === "online" || device.session?.state === "online";
return (
<button
key={device.deviceRef}
type="button"
className="device-inventory-row"
role="row"
onClick={() => setSelectedDeviceRef(device.deviceRef)}
>
<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">{device.identifier?.masked || "не назначен"}</span>
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
<span role="cell">{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</span>
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
</button>
);
})}
</div>
)}
</div>
);
}
function DeviceDetailView({
device,
workspace,
onBack,
}: {
device: DeviceView;
workspace: ProjectWorkspace;
onBack: () => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const [activeSectionId, setActiveSectionId] = useState(catalog.sections[0]?.id ?? "passport");
const detailRef = useRef<HTMLDivElement>(null);
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 context = useMemo(() => ({
device,
profile,
session,
configurationState,
reported: device.reported ?? {},
policies: {
...workspace.policies,
firmwareUpdate: "blocked",
},
}), [configurationState, device, profile, session, workspace.policies]);
const activeSection = catalog.sections.find(
(section) => section.id === activeSectionId,
) ?? catalog.sections[0];
useEffect(() => {
setActiveSectionId(catalog.sections[0]?.id ?? "passport");
}, [catalog.profileRef, device.deviceRef]);
useEffect(() => {
const panelBody = detailRef.current?.closest<HTMLElement>(".nodedc-application-panel__body");
if (panelBody) panelBody.scrollTop = 0;
}, [activeSectionId, device.deviceRef]);
if (!activeSection) return null;
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>{device.identifier?.masked || "Идентификатор не назначен"} · {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>
<div className="device-detail__layout">
<nav className="device-detail__navigation" aria-label="Разделы устройства">
{catalog.sections.map((section) => (
<button
key={section.id}
type="button"
className="device-detail__navigation-item"
data-active={section.id === activeSection.id ? "true" : undefined}
aria-current={section.id === activeSection.id ? "page" : undefined}
onClick={() => setActiveSectionId(section.id)}
>
<span>{section.label}</span>
<small>{accessLabel(section.access)}</small>
</button>
))}
</nav>
<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 value = readPath(context, item.path);
return (
<div key={item.key} className="device-detail-field" data-access={access}>
<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}
</div>
);
})}
</div>
<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 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 <div className="device-detail-notice" data-access="read-only"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></div>;
}
if (access === "protected") {
return <div className="device-detail-notice" data-access="protected"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></div>;
}
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Настройка управляется через типизированный command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
}
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;
@@ -1,771 +0,0 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
GlassSurface,
HeaderProfile,
HeaderWorkspace,
Icon,
IconButton,
Select,
SettingsCard,
StatusBadge,
TextAreaField,
TextField,
UserProfileMenu,
Window,
WindowFooterActions,
useApplicationWorkspace,
} from "@nodedc/ui-react";
import {
claimDevice,
ensureCollection,
ensureEnrollmentIntent,
ensureOwnerScope,
ensureProject,
loadProjects,
loadSession,
loadWorkspace,
} from "./api";
import type {
DeviceManagerSession,
EnrollmentView,
OwnerScopeClaim,
ProjectSummary,
ProjectWorkspace,
} from "./types";
import {
DeviceControlView,
type ControlViewId,
} from "./DeviceControlViews";
import { DeviceInventoryView } from "./DeviceInventoryView";
type ViewId =
| "overview"
| "inventory"
| "discovery"
| "collections"
| ControlViewId;
const navigationItems = [
{ id: "overview", label: "Обзор", icon: "grid", capability: null },
{ id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" },
{ id: "discovery", label: "Подключение", icon: "network", capability: "inventory.read" },
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
{ id: "sessions", label: "Сессии", icon: "activity", capability: "telemetry.observe" },
{ id: "bindings", label: "Bindings", icon: "external", capability: "binding.manage" },
{ id: "commands", label: "Команды", icon: "target", capability: "command.plan" },
{ id: "audit", label: "Аудит", icon: "clipboard", capability: "audit.read" },
{ id: "access", label: "Доступ", icon: "users", capability: "access.manage" },
{ id: "settings", label: "Настройки", icon: "settings", capability: "configuration.read" },
] as const;
export function DeviceManagerApp() {
const shell = useApplicationWorkspace<ViewId>({ navigationOpen: true });
const [session, setSession] = useState<DeviceManagerSession | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [workspace, setWorkspace] = useState<ProjectWorkspace | null>(null);
const [activeOwnerRef, setActiveOwnerRef] = useState("");
const [activeProjectRef, setActiveProjectRef] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false);
const [claimEnrollment, setClaimEnrollment] = useState<EnrollmentView | null>(null);
const refreshProjects = async () => {
const next = await loadProjects();
setProjects(next);
setActiveProjectRef((current) =>
next.some((project) => project.projectRef === current)
? current
: next[0]?.projectRef ?? ""
);
return next;
};
useEffect(() => {
let active = true;
Promise.all([loadSession(), loadProjects()])
.then(([nextSession, nextProjects]) => {
if (!active) return;
setSession(nextSession);
setProjects(nextProjects);
const ownerRef = nextProjects[0]?.ownerScope.ownerRef
|| nextSession.actor.ownerScopes[0]?.ownerRef
|| "";
setActiveOwnerRef(ownerRef);
setActiveProjectRef(nextProjects[0]?.projectRef ?? "");
})
.catch((reason) => active && setError(errorText(reason)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, []);
useEffect(() => {
if (!activeProjectRef) {
setWorkspace(null);
return;
}
let active = true;
loadWorkspace(activeProjectRef)
.then((next) => active && setWorkspace(next))
.catch((reason) => active && setError(errorText(reason)));
return () => { active = false; };
}, [activeProjectRef]);
const ownerScopes = useMemo(
() => mergeOwnerScopes(session?.actor.ownerScopes ?? [], projects),
[projects, session],
);
const visibleProjects = useMemo(
() => projects.filter((project) => !activeOwnerRef || project.ownerScope.ownerRef === activeOwnerRef),
[activeOwnerRef, projects],
);
const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
const creatableOwnerScopes = session?.actor.ownerScopes ?? [];
const capabilities = new Set(activeProject?.access.capabilities ?? []);
const canCreateProject = creatableOwnerScopes.length > 0;
const canManageCollections = capabilities.has("collection.manage");
const canEnroll = capabilities.has("device.enroll");
const canClaim = capabilities.has("device.claim");
const visibleNavigationItems = navigationItems.filter(
(item) => item.capability === null || capabilities.has(item.capability),
);
useEffect(() => {
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
}, [activeOwnerRef, ownerScopes]);
useEffect(() => {
if (
activeProjectRef
&& !visibleProjects.some((project) => project.projectRef === activeProjectRef)
) {
setActiveProjectRef(visibleProjects[0]?.projectRef ?? "");
setWorkspace(null);
}
}, [activeOwnerRef, activeProjectRef, visibleProjects]);
const openProject = (projectRef: string, view: ViewId = "overview") => {
setActiveProjectRef(projectRef);
shell.openView(view);
};
const refreshWorkspace = async () => {
if (!activeProjectRef) return;
const next = await loadWorkspace(activeProjectRef);
setWorkspace(next);
await refreshProjects();
};
const pollWorkspace = async () => {
if (!activeProjectRef) return;
const next = await loadWorkspace(activeProjectRef);
setWorkspace(next);
};
if (loading || !session) {
return <div className="device-manager-boot">Подключаем Device Core</div>;
}
const activeView = shell.activeView ?? "overview";
return (
<>
<ApplicationShell
header={
<AppHeader
brand={<img className="device-manager-brand" src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
center={
<button
className="device-manager-workspace-trigger"
type="button"
aria-label="Открыть навигацию Device Core"
onClick={shell.toggleNavigation}
>
<HeaderWorkspace kind="mark" label="Device Core" imageUrl="/nodedc-mark.svg" />
</button>
}
right={
<HeaderProfile>
<UserProfileMenu
displayName={session.user.displayName}
subtitle={session.user.email}
avatarUrl={session.user.avatarUrl ?? undefined}
actions={[
{ id: "profile", label: "Профиль", icon: "profile", href: session.profileUrl },
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
]}
/>
</HeaderProfile>
}
/>
}
stage={
<DeviceStage
projects={visibleProjects}
onOpenProject={openProject}
error={error}
onDismissError={() => setError(null)}
/>
}
navigationOpen={shell.navigationOpen}
navigation={
<AdminNavigationPanel
eyebrow="DEVICE CORE"
title="Проекты"
headerActions={
<IconButton
label="Новый проект"
disabled={!canCreateProject}
onClick={() => setProjectDialogOpen(true)}
>
<Icon name="plus" size={16} />
</IconButton>
}
contextSlot={
ownerScopes.length ? (
<div className="device-manager-navigation-context">
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={setActiveOwnerRef}
searchable={ownerScopes.length > 6}
/>
{visibleProjects.length ? (
<Select
value={activeProjectRef}
label="Проект"
options={visibleProjects.map((project) => ({
value: project.projectRef,
label: project.name,
description: project.access.projectRole || "read",
}))}
onChange={(projectRef) => openProject(projectRef)}
searchable={visibleProjects.length > 6}
/>
) : null}
</div>
) : null
}
items={activeProject ? visibleNavigationItems.map((item) => ({
id: item.id,
label: item.label,
icon: <Icon name={item.icon} />,
})) : []}
activeId={activeProject ? activeView : undefined}
footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>}
onClose={shell.closeNavigation}
onItemChange={(id) => shell.openView(id as ViewId)}
/>
}
contentOpen={shell.contentOpen}
contentExpanded={shell.contentExpanded}
content={
activeProject ? (
<ApplicationPanel
eyebrow={activeProject.ownerScope.displayName}
title={viewTitle(activeView)}
description={activeProject.name}
expanded={shell.contentExpanded}
onExpandedChange={shell.setContentExpanded}
onClose={shell.closeView}
utilityActions={[{
label: "Обновить данные",
icon: "refresh",
onClick: () => refreshWorkspace().catch((reason) => setError(errorText(reason))),
}]}
>
<ProjectView
view={activeView}
workspace={workspace}
canManageCollections={canManageCollections}
canEnroll={canEnroll}
canClaim={canClaim}
session={session}
onRefresh={refreshWorkspace}
onPoll={pollWorkspace}
onError={(reason) => setError(errorText(reason))}
onCreateCollection={() => setCollectionDialogOpen(true)}
onCreateEnrollment={() => setEnrollmentDialogOpen(true)}
onClaim={setClaimEnrollment}
/>
</ApplicationPanel>
) : null
}
/>
<ProjectDialog
open={projectDialogOpen}
ownerScopes={creatableOwnerScopes}
initialOwnerRef={activeOwnerRef}
onClose={() => setProjectDialogOpen(false)}
onCreated={async () => {
setProjectDialogOpen(false);
await refreshProjects();
}}
onError={(reason) => setError(errorText(reason))}
/>
<CollectionDialog
open={collectionDialogOpen}
project={activeProject}
onClose={() => setCollectionDialogOpen(false)}
onCreated={async () => {
setCollectionDialogOpen(false);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
<ClaimDialog
enrollment={claimEnrollment}
project={activeProject}
onClose={() => setClaimEnrollment(null)}
onClaimed={async () => {
setClaimEnrollment(null);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
<EnrollmentDialog
open={enrollmentDialogOpen}
workspace={workspace}
onClose={() => setEnrollmentDialogOpen(false)}
onCreated={async () => {
setEnrollmentDialogOpen(false);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
</>
);
}
function DeviceStage({
projects,
onOpenProject,
error,
onDismissError,
}: {
projects: ProjectSummary[];
onOpenProject: (projectRef: string, view?: ViewId) => void;
error: string | null;
onDismissError: () => void;
}) {
return (
<div className="device-manager-stage">
{error ? (
<div className="device-manager-alert" role="alert">
<Icon name="alert" />
<span>{error}</span>
<Button variant="ghost" size="compact" onClick={onDismissError}>Закрыть</Button>
</div>
) : null}
<GlassSurface className="device-manager-home" padding="lg" tone="soft">
<section className="device-manager-hero">
<span>NODEDC / DEVICE CORE</span>
<h1>Device Core</h1>
<p>Единый контур подключения, учёта и управления устройствами.</p>
</section>
<section className="device-manager-projects">
<div className="device-manager-section-title">
<div><span>DEVICE PROJECTS</span><h2>Рабочие проекты</h2></div>
<small>Доступ определяется Hub и project grants</small>
</div>
{projects.length ? (
<div className="device-manager-project-grid">
{projects.map((project) => (
<button key={project.projectRef} type="button" className="device-project-card" onClick={() => onOpenProject(project.projectRef)}>
<span className="device-project-card__icon"><Icon name="apps" size={20} /></span>
<span className="device-project-card__body">
<small>{project.ownerScope.displayName}</small>
<strong>{project.name}</strong>
<span>{project.description || "Универсальный проект устройств"}</span>
</span>
<span className="device-project-card__stats">
<b>{project.counts.devices}</b> устройств
<b>{project.counts.collections}</b> коллекций
</span>
<StatusBadge tone={project.lifecycleState === "active" ? "success" : "warning"}>
{project.access.projectRole || "read"}
</StatusBadge>
</button>
))}
</div>
) : (
<GlassSurface className="device-manager-empty" padding="lg" tone="soft">
<Icon name="inbox" size={24} />
<h3>В этом контуре пока нет проектов</h3>
<p>Создание доступно только в owner scope, подтверждённом Hub.</p>
</GlassSurface>
)}
</section>
</GlassSurface>
</div>
);
}
function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) {
return (
<GlassSurface className="device-manager-metric" padding="md" tone="soft">
<span>{label}</span>
<strong>{value}</strong>
<StatusBadge tone={tone}>{detail}</StatusBadge>
</GlassSurface>
);
}
function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onPoll, onError, onCreateCollection, onCreateEnrollment, onClaim }: {
view: ViewId;
workspace: ProjectWorkspace | null;
canManageCollections: boolean;
canEnroll: boolean;
canClaim: boolean;
session: DeviceManagerSession;
onRefresh: () => Promise<void>;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
onCreateCollection: () => void;
onCreateEnrollment: () => void;
onClaim: (enrollment: EnrollmentView) => void;
}) {
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект</div>;
if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
return <DeviceControlView
view={view as ControlViewId}
workspace={workspace}
session={session}
onRefresh={onRefresh}
onError={onError}
/>;
}
if (view === "inventory") return (
<DeviceInventoryView
workspace={workspace}
canEnroll={canEnroll}
onCreateEnrollment={onCreateEnrollment}
onPoll={onPoll}
onError={onError}
/>
);
if (view === "discovery") return (
<div className="device-manager-stack">
<div className="device-manager-panel-toolbar">
<p>Заранее разрешите конкретный идентификатор на активном маршруте. Core сохранит только HMAC и маску.</p>
<Button
icon={<Icon name="plus" />}
variant="primary"
disabled={!canEnroll || !workspace.routes.some((route) => route.lifecycleState === "active")}
onClick={onCreateEnrollment}
>
Подключить устройство
</Button>
</div>
{workspace.enrollments.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">Enrollment и quarantine evidence остаются связанными; raw identifier в интерфейс не передаётся.</p>
</SettingsCard>
))}
{!workspace.enrollments.length ? <div className="device-manager-panel-empty">Нет ожидающих подключений.</div> : null}
</div>
);
if (view === "collections") return (
<div className="device-manager-stack">
<div className="device-manager-panel-toolbar">
<p>Коллекции группируют устройства для последующих bindings и задач.</p>
<Button icon={<Icon name="plus" />} disabled={!canManageCollections} onClick={onCreateCollection}>Новая коллекция</Button>
</div>
<EntityList
empty="Коллекций пока нет."
items={workspace.collections.map((collection) => ({
id: collection.collectionRef,
title: collection.name,
subtitle: collection.description || collection.collectionKey,
status: `${collection.memberCount} устройств`,
tone: "neutral",
}))}
/>
</div>
);
return (
<div className="device-manager-overview-grid">
<Metric label="Устройства" value={workspace.devices.length} detail="inventory" />
<Metric label="Quarantine" value={workspace.discoveries.filter((item) => item.lifecycleState === "quarantine").length} detail="safe projection" />
<Metric label="Коллекции" value={workspace.collections.length} detail="bindings ready" />
<SettingsCard title="Права проекта" description={workspace.project.access.projectRole || "read"}>
<div className="device-manager-capabilities">
{workspace.project.access.capabilities.map((capability) => <span key={capability}>{capability}</span>)}
</div>
</SettingsCard>
</div>
);
}
function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) {
if (!items.length) return <div className="device-manager-panel-empty">{empty}</div>;
return <div className="device-manager-entity-list">{items.map((item) => (
<GlassSurface key={item.id} className="device-manager-entity" padding="md" tone="soft">
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
<span className="device-manager-entity__body"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
<StatusBadge tone={item.tone}>{item.status}</StatusBadge>
</GlassSurface>
))}</div>;
}
function ProjectDialog({ open, ownerScopes, initialOwnerRef, onClose, onCreated, onError }: {
open: boolean;
ownerScopes: OwnerScopeClaim[];
initialOwnerRef: string;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [description, setDescription] = useState("");
const [ownerRef, setOwnerRef] = useState(initialOwnerRef);
const [pending, setPending] = useState(false);
useEffect(() => {
if (!open) return;
setOwnerRef(ownerScopes.some((scope) => scope.ownerRef === initialOwnerRef)
? initialOwnerRef
: ownerScopes[0]?.ownerRef ?? "");
}, [initialOwnerRef, open, ownerScopes]);
const ownerScope = ownerScopes.find((scope) => scope.ownerRef === ownerRef) ?? null;
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!ownerScope) return;
setPending(true);
try {
await ensureOwnerScope(ownerScope);
await ensureProject({
scopeKind: ownerScope.scopeKind,
ownerRef: ownerScope.ownerRef,
projectKey: key,
name,
description: description.trim() || null,
});
setName(""); setKey(""); setDescription("");
await onCreated();
} catch (reason) { onError(reason); } finally { setPending(false); }
};
return <Window open={open} title="Новый Device Project" subtitle={ownerScope?.displayName || "Owner scope недоступен"} onClose={onClose} footer={
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-project-form" variant="primary" disabled={pending || !ownerScope}>{pending ? "Создаём…" : "Создать"}</Button></WindowFooterActions>
}>
<form id="device-project-form" className="device-manager-form" onSubmit={submit}>
<Select
label="Контур проекта"
value={ownerRef}
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Коммерческий контур" : "Личный контур",
}))}
onChange={setOwnerRef}
/>
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ проекта" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." />
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
</form>
</Window>;
}
function CollectionDialog({ open, project, onClose, onCreated, onError }: {
open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise<void>; onError: (reason: unknown) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [description, setDescription] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault(); if (!project) return; setPending(true);
try {
await ensureCollection({ projectRef: project.projectRef, collectionKey: key, name, description: description.trim() || null });
setName(""); setKey(""); setDescription(""); await onCreated();
} catch (reason) { onError(reason); } finally { setPending(false); }
};
return <Window open={open} title="Новая коллекция" subtitle={project?.name} onClose={onClose} footer={
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-collection-form" variant="primary" disabled={pending || !project}>{pending ? "Сохраняем…" : "Создать"}</Button></WindowFooterActions>
}>
<form id="device-collection-form" className="device-manager-form" onSubmit={submit}>
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ коллекции" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
</form>
</Window>;
}
function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise<void>; onError: (reason: unknown) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => { if (enrollment) setName(enrollment.displayName); }, [enrollment]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!project || !enrollment?.observedDiscoveryRef) return;
setPending(true);
try {
await claimDevice({
projectRef: project.projectRef,
enrollmentIntentRef: enrollment.enrollmentIntentRef,
discoveryRef: enrollment.observedDiscoveryRef,
deviceKey: key,
displayName: name,
});
setName(""); setKey(""); await onClaimed();
} catch (reason) { onError(reason); } finally { setPending(false); }
};
return <Window open={Boolean(enrollment)} title="Принять устройство" subtitle={enrollment?.expectedIdentifier.masked} onClose={onClose} footer={
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-claim-form" variant="primary" disabled={pending || !enrollment}>{pending ? "Проверяем…" : "Принять"}</Button></WindowFooterActions>
}>
<form id="device-claim-form" className="device-manager-form" onSubmit={submit}>
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ устройства" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<p className="device-manager-card-copy">Claim использует только ссылки на enrollment и discovery. Исходный идентификатор не запрашивается повторно.</p>
</form>
</Window>;
}
function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: {
open: boolean;
workspace: ProjectWorkspace | null;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const activeRoutes = useMemo(
() => workspace?.routes.filter((route) => route.lifecycleState === "active") ?? [],
[workspace],
);
const [routeRef, setRouteRef] = useState("");
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [imei, setImei] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => {
if (!activeRoutes.some((route) => route.routeRef === routeRef)) {
setRouteRef(activeRoutes[0]?.routeRef ?? "");
}
}, [activeRoutes, routeRef]);
useEffect(() => {
if (!open) setImei("");
}, [open]);
const submit = async (event: FormEvent) => {
event.preventDefault();
const route = activeRoutes.find((item) => item.routeRef === routeRef);
if (!workspace || !route) return;
setPending(true);
try {
await ensureEnrollmentIntent({
projectRef: workspace.project.projectRef,
enrollmentKey: key,
routeRef: route.routeRef,
modelProfileRef: route.modelProfileRef,
displayName: name,
identifier: { kind: "imei", value: imei },
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
});
setImei("");
setName("");
setKey("");
setExpiresAt("");
await onCreated();
} catch (reason) {
onError(reason);
} finally {
setPending(false);
}
};
return <Window open={open} title="Подключить устройство" subtitle={workspace?.project.name} onClose={onClose} footer={
<WindowFooterActions>
<Button variant="ghost" onClick={onClose}>Отмена</Button>
<Button type="submit" form="device-enrollment-form" variant="primary" disabled={pending || !workspace || !routeRef}>
{pending ? "Защищаем идентификатор…" : "Создать enrollment"}
</Button>
</WindowFooterActions>
}>
<form id="device-enrollment-form" className="device-manager-form" onSubmit={submit}>
<Select
label="Активный маршрут"
value={routeRef}
onChange={setRouteRef}
options={activeRoutes.map((route) => ({
value: route.routeRef,
label: route.displayName,
description: `${route.protocol} · ${route.modelProfileRef}`,
}))}
/>
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ enrollment" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<TextField label="IMEI" value={imei} onChange={(event) => setImei(event.target.value.replace(/\D/g, "").slice(0, 15))} required pattern="[0-9]{15}" inputMode="numeric" autoComplete="off" />
<TextField label="Истекает" type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} description="Опционально. Время будет сохранено в UTC." />
<p className="device-manager-card-copy">IMEI передаётся один раз по HTTPS в Device Control Core. В базе, audit и ответе останутся только HMAC и маска.</p>
</form>
</Window>;
}
function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[]) {
const scopes = new Map(claims.map((scope) => [scope.ownerRef, scope]));
for (const project of projects) {
if (!scopes.has(project.ownerScope.ownerRef)) {
scopes.set(project.ownerScope.ownerRef, {
scopeKind: project.ownerScope.scopeKind,
ownerRef: project.ownerScope.ownerRef,
displayName: project.ownerScope.displayName,
});
}
}
return [...scopes.values()];
}
function viewTitle(view: ViewId) {
return ({
overview: "Обзор проекта",
inventory: "Устройства",
discovery: "Подключение",
collections: "Коллекции",
catalog: "Модели и адаптеры",
infrastructure: "Edges и маршруты",
sessions: "Gateway sessions",
bindings: "Data bindings",
commands: "Command ledger",
audit: "Immutable audit",
access: "Доступ к проекту",
settings: "Настройки и конфигурации",
})[view];
}
function errorText(reason: unknown) {
const value = reason instanceof Error ? reason.message : String(reason || "device_manager_error");
const labels: Record<string, string> = {
device_project_capability_denied: "Недостаточно прав в выбранном проекте.",
device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.",
device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.",
};
return labels[value] || value;
}
-252
View File
@@ -1,252 +0,0 @@
import type {
AdapterPackageView,
AdapterVersionView,
BindingView,
ConfigurationRevisionView,
EdgeView,
ModelProfileView,
ProjectGrantView,
DeviceManagerSession,
ProjectSummary,
ProjectWorkspace,
RouteView,
ScopeKind,
} from "./types";
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 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 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;
}
@@ -1,465 +0,0 @@
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: [
field("display-name", "Название", "device.displayName"),
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("identifier", "Идентификатор", "device.identifier.masked"),
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];
}
-17
View File
@@ -1,17 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { applyNodedcTheme } from "@nodedc/ui-core";
import "@nodedc/ui-core/styles.css";
import "./styles.css";
import { DeviceManagerApp } from "./DeviceManagerApp";
applyNodedcTheme(document.documentElement, {
theme: "dark",
accent: [185, 255, 74],
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<DeviceManagerApp />
</StrictMode>,
);
-962
View File
@@ -1,962 +0,0 @@
:root {
color-scheme: dark;
background: #0b0d0f;
}
.device-control-stack {
gap: 22px;
}
.device-control-toolbar {
align-items: center;
padding: 2px 0 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.device-control-toolbar > p {
max-width: 720px;
margin: 0;
color: rgba(255, 255, 255, 0.6);
line-height: 1.55;
}
.device-control-toolbar__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.device-control-section {
display: grid;
gap: 12px;
}
.device-control-section__title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.device-control-section__title h3 {
margin: 0;
font-size: 15px;
letter-spacing: -0.01em;
}
.device-control-resource-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.device-control-row {
min-width: 0;
}
.device-control-row__status {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
}
.device-control-row__status small {
max-width: 260px;
overflow: hidden;
color: rgba(255, 255, 255, 0.45);
text-overflow: ellipsis;
white-space: nowrap;
}
.device-control-command-policy {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 18px;
border: 1px solid rgba(255, 193, 92, 0.22);
border-radius: 22px;
background: rgba(255, 193, 92, 0.06);
}
.device-control-command-policy > svg {
color: #ffc15c;
}
.device-control-command-policy strong,
.device-control-command-policy p {
display: block;
margin: 0;
}
.device-control-command-form {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
gap: 14px;
padding: 18px;
border: 1px solid rgba(181, 255, 44, 0.18);
border-radius: 22px;
background: rgba(181, 255, 44, 0.045);
}
.device-control-command-policy p {
margin-top: 5px;
color: rgba(255, 255, 255, 0.58);
font-size: 13px;
line-height: 1.5;
}
.device-control-policy-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.device-control-policy-grid .nodedc-glass-surface {
display: grid;
gap: 8px;
}
.device-control-policy-grid small {
color: rgba(255, 255, 255, 0.46);
}
.device-control-policy-grid strong {
font-size: 14px;
}
.device-control-form {
max-height: min(62vh, 680px);
overflow-y: auto;
padding-right: 3px;
}
@media (max-width: 760px) {
.device-control-resource-grid,
.device-control-policy-grid {
grid-template-columns: 1fr;
}
.device-control-toolbar {
align-items: stretch;
}
.device-control-toolbar__actions,
.device-control-toolbar__actions > * {
width: 100%;
}
.device-control-command-policy {
grid-template-columns: auto 1fr;
}
.device-control-command-form {
grid-template-columns: 1fr;
}
.device-control-command-policy > .nodedc-status {
grid-column: 2;
justify-self: start;
}
.device-control-row {
grid-template-columns: auto minmax(0, 1fr);
}
.device-control-row__status {
grid-column: 2;
justify-content: flex-start;
}
}
html,
body,
#root {
width: 100%;
min-width: 320px;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background:
radial-gradient(circle at 76% 18%, rgb(185 255 74 / 0.09), transparent 34%),
radial-gradient(circle at 12% 84%, rgb(79 127 255 / 0.08), transparent 31%),
#0b0d0f;
}
button,
input,
textarea {
font: inherit;
}
.device-manager-boot {
display: grid;
height: 100%;
place-items: center;
color: var(--nodedc-text-secondary);
font: 500 0.9rem/1.4 var(--nodedc-font-family);
}
.device-manager-brand {
display: block;
width: 124px;
height: auto;
}
.device-manager-workspace-trigger {
display: inline-grid;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.device-manager-stage {
height: 100%;
overflow: auto;
padding: clamp(1rem, 2.5vw, 2.5rem);
scrollbar-width: thin;
}
.device-manager-home {
display: flex;
min-height: calc(100% - 1px);
max-width: 1420px;
flex-direction: column;
justify-content: center;
gap: clamp(3rem, 7vh, 6rem);
margin: 0 auto;
padding: clamp(2.5rem, 6vw, 6rem) !important;
}
.device-manager-hero {
max-width: 900px;
}
.device-manager-hero > span,
.device-manager-section-title span {
color: rgb(var(--nodedc-accent-rgb));
font-size: 0.68rem;
font-weight: 760;
letter-spacing: 0.16em;
}
.device-manager-hero h1 {
margin: 0.7rem 0 0.9rem;
color: var(--nodedc-text-primary);
font-size: clamp(4rem, 8vw, 8.5rem);
font-weight: 520;
letter-spacing: -0.065em;
line-height: 0.9;
}
.device-manager-hero p {
max-width: 680px;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: clamp(0.96rem, 1.4vw, 1.14rem);
line-height: 1.55;
}
.device-manager-alert {
display: flex;
max-width: 1420px;
align-items: center;
gap: 0.75rem;
margin: 0 auto 1.25rem;
border-radius: 18px;
background: rgb(var(--nodedc-danger-rgb) / 0.11);
color: var(--nodedc-text-primary);
padding: 0.7rem 0.8rem 0.7rem 1rem;
}
.device-manager-alert span {
flex: 1;
}
.device-manager-metrics {
display: grid;
max-width: 1420px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
margin: 0 auto 3.5rem;
}
.device-manager-metric {
display: grid;
min-height: 142px;
align-content: space-between;
gap: 0.4rem;
}
.device-manager-metric > span:first-child {
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
}
.device-manager-metric > strong {
color: var(--nodedc-text-primary);
font-size: clamp(2rem, 3vw, 3.25rem);
font-weight: 520;
letter-spacing: -0.05em;
}
.device-manager-metric .nodedc-status {
width: max-content;
}
.device-manager-projects {
width: 100%;
}
.device-manager-navigation-context {
display: grid;
gap: 0.65rem;
}
.device-manager-section-title {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.15rem;
}
.device-manager-section-title h2 {
margin: 0.35rem 0 0;
font-size: 1.45rem;
font-weight: 560;
}
.device-manager-section-title small {
color: var(--nodedc-text-muted);
}
.device-manager-project-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 1rem;
}
.device-project-card {
display: grid;
min-height: 160px;
grid-template-columns: auto 1fr auto;
grid-template-rows: 1fr auto;
gap: 1rem;
border: 0;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-glass-panel-bg-soft);
color: var(--nodedc-text-primary);
padding: 1.25rem;
text-align: left;
box-shadow: var(--nodedc-glass-panel-shadow);
cursor: pointer;
transition: background 160ms ease, transform 160ms ease;
}
.device-project-card:hover {
background: var(--nodedc-glass-control-hover);
transform: translateY(-2px);
}
.device-project-card__icon,
.device-manager-entity__icon {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border-radius: 50%;
background: rgb(var(--nodedc-accent-rgb) / 0.12);
color: rgb(var(--nodedc-accent-rgb));
}
.device-project-card__body {
display: grid;
align-content: start;
gap: 0.35rem;
}
.device-project-card__body small,
.device-project-card__body span,
.device-project-card__stats {
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
line-height: 1.45;
}
.device-project-card__body strong {
font-size: 1.05rem;
}
.device-project-card__stats {
display: flex;
grid-column: 1 / 3;
align-items: center;
gap: 0.45rem;
}
.device-project-card__stats b {
color: var(--nodedc-text-primary);
font-size: 0.95rem;
}
.device-project-card > .nodedc-status {
grid-column: 3;
align-self: end;
}
.device-manager-empty {
display: grid;
min-height: 220px;
place-items: center;
align-content: center;
gap: 0.65rem;
text-align: center;
}
.device-manager-empty h3,
.device-manager-empty p {
margin: 0;
}
.device-manager-empty p,
.device-manager-card-copy {
color: var(--nodedc-text-secondary);
font-size: 0.82rem;
line-height: 1.55;
}
.device-manager-panel-empty {
display: grid;
min-height: 220px;
place-items: center;
color: var(--nodedc-text-secondary);
text-align: center;
}
.device-manager-stack,
.device-manager-entity-list {
display: grid;
gap: 0.85rem;
}
.device-manager-panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.4rem;
}
.device-manager-panel-toolbar p {
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.82rem;
}
.device-manager-entity {
display: flex;
align-items: center;
gap: 0.85rem;
}
.device-manager-entity__icon {
width: 34px;
height: 34px;
}
.device-manager-entity__body {
display: grid;
flex: 1;
gap: 0.18rem;
}
.device-manager-entity__body small {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-manager-overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.device-manager-overview-grid .nodedc-settings-card {
grid-column: 1 / -1;
}
.device-manager-capabilities {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.device-manager-capabilities span {
border-radius: 999px;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
padding: 0.45rem 0.7rem;
font-size: 0.72rem;
}
.device-inventory {
display: grid;
gap: 1rem;
}
.device-inventory__toolbar > div:first-child {
display: grid;
gap: 0.2rem;
}
.device-inventory__toolbar strong {
font-size: 0.95rem;
}
.device-inventory__empty {
min-height: 280px;
}
.device-inventory-table {
overflow: hidden;
border: 1px solid var(--nodedc-glass-border);
border-radius: 20px;
background: color-mix(in srgb, var(--nodedc-glass-surface-bg) 86%, transparent);
}
.device-inventory-table__head,
.device-inventory-row {
display: grid;
grid-template-columns: minmax(180px, 1.45fr) minmax(130px, 1fr) minmax(140px, 1fr) minmax(90px, 0.7fr) minmax(150px, 0.9fr) 28px;
align-items: center;
gap: 0.8rem;
}
.device-inventory-table__head {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--nodedc-glass-border);
color: var(--nodedc-text-tertiary);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.device-inventory-row {
width: 100%;
border: 0;
border-bottom: 1px solid var(--nodedc-glass-border);
background: transparent;
color: inherit;
padding: 0.9rem 1rem;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 160ms ease, transform 160ms ease;
}
.device-inventory-row:last-child {
border-bottom: 0;
}
.device-inventory-row:hover,
.device-inventory-row:focus-visible {
outline: none;
background: var(--nodedc-glass-control-bg);
}
.device-inventory-row:active {
transform: translateY(1px);
}
.device-inventory-row > span {
min-width: 0;
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-inventory-row__device {
display: flex;
align-items: center;
gap: 0.7rem;
}
.device-inventory-row__device > span:last-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.device-inventory-row__device strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.82rem;
text-overflow: ellipsis;
}
.device-inventory-row__device small {
overflow: hidden;
color: var(--nodedc-text-tertiary);
font-size: 0.67rem;
text-overflow: ellipsis;
}
.device-inventory-row__icon {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
border-radius: 10px;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-accent, #b9ff37);
}
.device-inventory-row__open {
display: grid;
justify-items: end;
color: var(--nodedc-text-tertiary) !important;
}
.device-detail {
display: grid;
gap: 1rem;
}
.device-detail__header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--nodedc-glass-border);
}
.device-detail__identity {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.device-detail__identity small {
color: var(--nodedc-accent, #b9ff37);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.device-detail__identity h2,
.device-detail__identity p {
margin: 0;
}
.device-detail__identity h2 {
overflow: hidden;
font-size: clamp(1.15rem, 2vw, 1.55rem);
text-overflow: ellipsis;
white-space: nowrap;
}
.device-detail__identity p {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.73rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-detail__legend {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.device-detail__layout {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
align-items: start;
gap: 1rem;
}
.device-detail__navigation {
display: grid;
overflow: hidden;
border: 1px solid var(--nodedc-glass-border);
border-radius: 16px;
background: color-mix(in srgb, var(--nodedc-glass-surface-bg) 82%, transparent);
}
.device-detail__navigation-item {
display: grid;
gap: 0.15rem;
border: 0;
border-bottom: 1px solid var(--nodedc-glass-border);
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0.68rem 0.8rem;
text-align: left;
cursor: pointer;
}
.device-detail__navigation-item:last-child {
border-bottom: 0;
}
.device-detail__navigation-item:hover,
.device-detail__navigation-item:focus-visible {
outline: none;
background: var(--nodedc-glass-control-bg);
}
.device-detail__navigation-item[data-active="true"] {
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-primary);
box-shadow: inset 3px 0 0 var(--nodedc-accent, #b9ff37);
}
.device-detail__navigation-item span {
font-size: 0.76rem;
font-weight: 700;
}
.device-detail__navigation-item small {
color: var(--nodedc-text-tertiary);
font-size: 0.58rem;
}
.device-detail-section {
display: grid;
min-width: 0;
gap: 0.9rem;
}
.device-detail-section__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.device-detail-section__heading > div {
display: grid;
gap: 0.28rem;
}
.device-detail-section__heading span:first-child {
color: var(--nodedc-text-tertiary);
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.device-detail-section__heading h3,
.device-detail-section__heading p {
margin: 0;
}
.device-detail-section__heading h3 {
font-size: 1.08rem;
}
.device-detail-section__heading p {
max-width: 760px;
color: var(--nodedc-text-secondary);
font-size: 0.75rem;
line-height: 1.55;
}
.device-detail-notice {
display: flex;
align-items: flex-start;
gap: 0.55rem;
border: 1px solid var(--nodedc-glass-border);
border-radius: 13px;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
padding: 0.7rem 0.8rem;
font-size: 0.7rem;
line-height: 1.45;
}
.device-detail-notice[data-access="managed"] {
border-color: color-mix(in srgb, var(--nodedc-accent, #b9ff37) 32%, var(--nodedc-glass-border));
}
.device-detail-notice[data-access="protected"] {
border-color: color-mix(in srgb, #ffb347 38%, var(--nodedc-glass-border));
}
.device-detail-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.device-detail-field {
display: grid;
min-width: 0;
gap: 0.42rem;
border: 1px solid var(--nodedc-glass-border);
border-radius: 14px;
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 78%, transparent);
padding: 0.75rem 0.8rem;
}
.device-detail-field[data-access="read-only"] {
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 58%, transparent);
}
.device-detail-field[data-access="protected"] {
border-color: color-mix(in srgb, #ffb347 26%, var(--nodedc-glass-border));
}
.device-detail-field__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
color: var(--nodedc-text-secondary);
font-size: 0.67rem;
}
.device-detail-field strong {
overflow-wrap: anywhere;
font-size: 0.8rem;
font-weight: 650;
line-height: 1.4;
}
.device-detail-field > small {
color: var(--nodedc-text-tertiary);
font-size: 0.62rem;
line-height: 1.45;
}
.device-access-badge--compact {
flex: 0 0 auto;
padding: 0.25rem 0.45rem !important;
font-size: 0.56rem !important;
}
.device-detail-section__state {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.35rem 0.7rem;
border-top: 1px solid var(--nodedc-glass-border);
padding-top: 0.85rem;
color: var(--nodedc-text-secondary);
font-size: 0.65rem;
}
.device-detail-section__state strong {
overflow-wrap: anywhere;
color: var(--nodedc-text-primary);
font-weight: 600;
}
.device-manager-form {
display: grid;
gap: 1rem;
}
.device-manager-form textarea {
min-height: 110px;
resize: vertical;
}
@media (max-width: 1100px) {
.device-manager-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
body { overflow: auto; }
.device-manager-stage {
padding: 1rem;
}
.device-manager-home {
min-height: auto;
padding: 2.5rem 1.25rem !important;
}
.device-manager-hero h1 {
font-size: clamp(3.4rem, 17vw, 6rem);
}
.device-manager-panel-toolbar,
.device-manager-section-title {
align-items: stretch;
flex-direction: column;
}
.device-manager-metrics,
.device-manager-overview-grid {
grid-template-columns: 1fr;
}
.device-manager-overview-grid .nodedc-settings-card {
grid-column: auto;
}
.device-project-card {
grid-template-columns: auto 1fr;
}
.device-project-card > .nodedc-status {
grid-column: 2;
justify-self: end;
}
.device-inventory-table__head {
display: none;
}
.device-inventory-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.device-inventory-row > span:not(.device-inventory-row__device):not(.device-inventory-row__open) {
display: none;
}
.device-detail__layout {
grid-template-columns: 1fr;
}
.device-detail__navigation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.device-detail__navigation-item:nth-child(odd) {
border-right: 1px solid var(--nodedc-glass-border);
}
.device-detail-fields {
grid-template-columns: 1fr;
}
.device-detail-section__heading {
align-items: stretch;
flex-direction: column;
}
}
-287
View File
@@ -1,287 +0,0 @@
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 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;
modelProfileRef: string;
lifecycleState: string;
identifier: { kind: string; masked: 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
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: { sourcemap: true },
});
-21
View File
@@ -43,23 +43,6 @@
"vite-plugin-static-copy": "^4.1.1" "vite-plugin-static-copy": "^4.1.1"
} }
}, },
"apps/device-manager": {
"name": "@nodedc/device-manager",
"version": "0.1.0",
"dependencies": {
"@nodedc/ui-core": "0.7.0",
"@nodedc/ui-react": "0.7.0",
"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"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -938,10 +921,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@nodedc/device-manager": {
"resolved": "apps/device-manager",
"link": true
},
"node_modules/@nodedc/map-cesium-react": { "node_modules/@nodedc/map-cesium-react": {
"resolved": "packages/map-cesium-react", "resolved": "packages/map-cesium-react",
"link": true "link": true