diff --git a/apps/device-manager/README.md b/apps/device-manager/README.md
deleted file mode 100644
index c57f78a..0000000
--- a/apps/device-manager/README.md
+++ /dev/null
@@ -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.
diff --git a/apps/device-manager/index.html b/apps/device-manager/index.html
deleted file mode 100644
index 30163c1..0000000
--- a/apps/device-manager/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- NODE.DC Device Core
-
-
-
-
-
-
diff --git a/apps/device-manager/package.json b/apps/device-manager/package.json
deleted file mode 100644
index 84885dd..0000000
--- a/apps/device-manager/package.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/apps/device-manager/public/nodedc-logo.svg b/apps/device-manager/public/nodedc-logo.svg
deleted file mode 100644
index 92b19d8..0000000
--- a/apps/device-manager/public/nodedc-logo.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/device-manager/public/nodedc-mark.svg b/apps/device-manager/public/nodedc-mark.svg
deleted file mode 100644
index 866cfe4..0000000
--- a/apps/device-manager/public/nodedc-mark.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/apps/device-manager/server/device-core-client.mjs b/apps/device-manager/server/device-core-client.mjs
deleted file mode 100644
index c097f16..0000000
--- a/apps/device-manager/server/device-core-client.mjs
+++ /dev/null
@@ -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;
-}
diff --git a/apps/device-manager/server/device-core-client.test.mjs b/apps/device-manager/server/device-core-client.test.mjs
deleted file mode 100644
index eed6595..0000000
--- a/apps/device-manager/server/device-core-client.test.mjs
+++ /dev/null
@@ -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" },
- });
-}
diff --git a/apps/device-manager/server/device-manager-auth.mjs b/apps/device-manager/server/device-manager-auth.mjs
deleted file mode 100644
index 542e6ed..0000000
--- a/apps/device-manager/server/device-manager-auth.mjs
+++ /dev/null
@@ -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;
-}
diff --git a/apps/device-manager/server/device-manager-auth.test.mjs b/apps/device-manager/server/device-manager-auth.test.mjs
deleted file mode 100644
index 4ddd205..0000000
--- a/apps/device-manager/server/device-manager-auth.test.mjs
+++ /dev/null
@@ -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" },
- });
-}
diff --git a/apps/device-manager/server/device-manager-server.mjs b/apps/device-manager/server/device-manager-server.mjs
deleted file mode 100644
index 3fd5858..0000000
--- a/apps/device-manager/server/device-manager-server.mjs
+++ /dev/null
@@ -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,
- }));
- });
-}
diff --git a/apps/device-manager/server/device-manager-server.test.mjs b/apps/device-manager/server/device-manager-server.test.mjs
deleted file mode 100644
index 0753362..0000000
--- a/apps/device-manager/server/device-manager-server.test.mjs
+++ /dev/null
@@ -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;
-}
diff --git a/apps/device-manager/src/DeviceControlViews.tsx b/apps/device-manager/src/DeviceControlViews.tsx
deleted file mode 100644
index a7eb2be..0000000
--- a/apps/device-manager/src/DeviceControlViews.tsx
+++ /dev/null
@@ -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;
- onError: (reason: unknown) => void;
-}) {
- const [dialog, setDialog] = useState(null);
- const capabilities = new Set(workspace.project.access.capabilities);
- const platformOwner = session.actor.hubRole === "owner";
- const close = () => setDialog(null);
- const completed = async () => {
- close();
- await onRefresh();
- };
- const mutateAndRefresh = async (mutation: () => Promise) => {
- try {
- await mutation();
- await onRefresh();
- } catch (reason) {
- onError(reason);
- }
- };
-
- return (
- <>
- {view === "catalog" ? (
- 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" ? (
- 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" ? : null}
- {view === "bindings" ? (
- setDialog("binding")}
- onRevoke={(binding) => revokeDeviceBinding({
- projectRef: workspace.project.projectRef,
- bindingRef: binding.bindingRef,
- resolutionCode: "operator.revoked",
- }).then(onRefresh).catch(onError)}
- />
- ) : null}
- {view === "commands" ? (
-
- ) : null}
- {view === "audit" ? : null}
- {view === "access" ? (
- setDialog("grant")}
- />
- ) : null}
- {view === "settings" ? (
- setDialog("configuration")}
- />
- ) : null}
-
-
-
-
-
-
-
-
-
- >
- );
-}
-
-function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: {
- workspace: ProjectWorkspace;
- canManage: boolean;
- onCreatePackage: () => void;
- onCreateVersion: () => void;
- onCreateProfile: () => void;
- onActivateVersion: (version: AdapterVersionView) => void;
- onActivateProfile: (profile: ModelProfileView) => void;
-}) {
- return (
-
-
- Пакет
- Версия
- Профиль
- > : null}
- />
-
-
- {workspace.modelProfiles.map((profile) => (
- onActivateProfile(profile)}>Активировать
- ) : null}
- />
- ))}
-
-
-
-
- {workspace.adapterVersions.map((version) => (
- onActivateVersion(version)}>Активировать
- ) : null}
- />
- ))}
-
-
-
-
- {workspace.adapterPackages.map((adapterPackage) => (
- version.adapterPackageRef === adapterPackage.adapterPackageRef)
- .map((version) => `${version.version} · ${version.lifecycleState}`)}
- />
- ))}
-
-
-
- );
-}
-
-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 (
-
-
- {canManageCatalog ? Новый Edge : null}
- {canManageRoutes ? Новый маршрут : null}
- >}
- />
-
-
- {workspace.routes.map((route) => (
- onActivateRoute(route)}>Активировать
- ) : null}
- />
- ))}
-
-
-
-
- {workspace.edges.map((edge) => (
- onActivateEdge(edge)}>Активировать
- ) : null}
- />
- ))}
-
-
-
- );
-}
-
-function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
- return (
-
-
-
- {workspace.sessions.map((session) => (
-
- ))}
-
-
- );
-}
-
-function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
- workspace: ProjectWorkspace;
- canManage: boolean;
- onCreate: () => void;
- onRevoke: (binding: BindingView) => void;
-}) {
- return (
-
- Новый binding : null}
- />
-
- {workspace.bindings.map((binding) => (
- onRevoke(binding)}>Отозвать
- ) : binding.capabilities.join(", ")}
- />
- ))}
-
-
- );
-}
-
-function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
- workspace: ProjectWorkspace;
- canDispatch: boolean;
- onRefresh: () => Promise;
- 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 (
-
-
-
-
-
{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}
-
{enabled
- ? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
- : "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}
-
-
{workspace.policies.commandTransport}
-
- {enabled ? (
-
- ) : null}
-
- {workspace.commands.map((command) => (
-
- ))}
-
-
- );
-}
-
-function AuditView({ workspace }: { workspace: ProjectWorkspace }) {
- return (
-
-
-
- {workspace.auditEvents.map((event) => (
-
- ))}
-
-
- );
-}
-
-function AccessView({ workspace, canManage, onCreate }: {
- workspace: ProjectWorkspace;
- canManage: boolean;
- onCreate: () => void;
-}) {
- return (
-
- Добавить доступ : null}
- />
-
- {workspace.grants.map((grant) => (
-
- ))}
-
-
- );
-}
-
-function SettingsView({ workspace, canConfigure, onCreateConfiguration }: {
- workspace: ProjectWorkspace;
- canConfigure: boolean;
- onCreateConfiguration: () => void;
-}) {
- return (
-
-
- Новая desired revision : null}
- />
-
- {workspace.configurationStates.map((state) => (
-
- ))}
-
-
-
- {workspace.configurationRevisions.map((revision) => (
-
- ))}
-
-
-
- );
-}
-
-function AdapterPackageDialog(props: DialogBaseProps) {
- const [packageKey, setPackageKey] = useState("");
- const [displayName, setDisplayName] = useState("");
- const [publisherRef, setPublisherRef] = useState("");
- return {
- await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" });
- }}>
-
- setDisplayName(event.target.value)} required />
- setPublisherRef(event.target.value)} required />
- ;
-}
-
-function AdapterVersionDialog({ packages, ...props }: DialogBaseProps & { packages: AdapterPackageView[] }) {
- const [packageRef, setPackageRef] = useState(packages[0]?.adapterPackageRef ?? "");
- const [version, setVersion] = useState("");
- const [runtimeRef, setRuntimeRef] = useState("");
- const [digest, setDigest] = useState("");
- const [contractVersion, setContractVersion] = useState("");
- const [capabilities, setCapabilities] = useState("");
- useEffect(() => {
- if (!packages.some((item) => item.adapterPackageRef === packageRef)) {
- setPackageRef(packages[0]?.adapterPackageRef ?? "");
- }
- }, [packageRef, packages]);
- return {
- await registerAdapterVersion({
- adapterPackageRef: packageRef,
- version,
- runtimePackageRef: runtimeRef,
- contentDigest: digest,
- contractVersion,
- capabilities: commaList(capabilities),
- lifecycleState: "draft",
- });
- }}>
- ({ value: item.adapterPackageRef, label: item.displayName }))} />
- setVersion(event.target.value)} required placeholder="1.0.0" />
- setRuntimeRef(event.target.value)} required />
- setDigest(event.target.value)} required placeholder="sha256:…" />
- setContractVersion(event.target.value)} required />
- setCapabilities(event.target.value)} description="Через запятую" />
- ;
-}
-
-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 {
- await registerModelProfile({
- adapterVersionRef: versionRef,
- profileRef,
- schemaVersion,
- vendor,
- model,
- deviceType,
- protocol: protocol.toUpperCase(),
- schemaArtifactRef: schemaRef,
- profileDigest: digest,
- capabilities: commaList(capabilities),
- lifecycleState: "draft",
- });
- }}>
- ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} />
- setProfileRef(event.target.value)} required />
- setSchemaVersion(event.target.value)} required />
- setVendor(event.target.value)} required />
- setModel(event.target.value)} required />
-
- setProtocol(event.target.value)} required />
- setSchemaRef(event.target.value)} required />
- setDigest(event.target.value)} required placeholder="sha256:…" />
- setCapabilities(event.target.value)} />
- ;
-}
-
-function EdgeDialog(props: DialogBaseProps) {
- const [edgeKey, setEdgeKey] = useState("");
- const [displayName, setDisplayName] = useState("");
- const [deploymentRef, setDeploymentRef] = useState("");
- return {
- await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" });
- }}>
-
- setDisplayName(event.target.value)} required />
- setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." />
- ;
-}
-
-function RouteDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
- const [routeKey, setRouteKey] = useState("");
- const [displayName, setDisplayName] = useState("");
- const [edgeRef, setEdgeRef] = useState(workspace.edges[0]?.edgeRef ?? "");
- const [profileRef, setProfileRef] = useState(workspace.modelProfiles[0]?.modelProfileRef ?? "");
- const [listenerRef, setListenerRef] = useState("");
- const profile = workspace.modelProfiles.find((item) => item.modelProfileRef === profileRef);
- useEffect(() => {
- if (!workspace.edges.some((item) => item.edgeRef === edgeRef)) {
- setEdgeRef(workspace.edges[0]?.edgeRef ?? "");
- }
- if (!workspace.modelProfiles.some((item) => item.modelProfileRef === profileRef)) {
- setProfileRef(workspace.modelProfiles[0]?.modelProfileRef ?? "");
- }
- }, [edgeRef, profileRef, workspace.edges, workspace.modelProfiles]);
- return {
- await ensureRoute({
- projectRef: workspace.project.projectRef,
- routeKey,
- displayName,
- edgeRef,
- modelProfileRef: profileRef,
- listenerRef,
- protocol: profile?.protocol || "INTERNAL",
- direction: "telemetry",
- lifecycleState: "draft",
- });
- }}>
-
- setDisplayName(event.target.value)} required />
- ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} />
- ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} />
- setListenerRef(event.target.value)} required />
- Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.
- ;
-}
-
-function BindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
- const sources = useMemo(() => [
- ...workspace.collections.map((item) => ({ value: `collection|${item.collectionRef}`, label: item.name })),
- ...workspace.devices.map((item) => ({ value: `device|${item.deviceRef}`, label: item.displayName })),
- ], [workspace]);
- const [sourceValue, setSourceValue] = useState(sources[0]?.value ?? "");
- const [bindingKey, setBindingKey] = useState("");
- const [displayName, setDisplayName] = useState("");
- const [targetKind, setTargetKind] = useState("");
- const [targetRef, setTargetRef] = useState("");
- const [capabilities, setCapabilities] = useState("observe");
- useEffect(() => {
- if (!sources.some((item) => item.value === sourceValue)) {
- setSourceValue(sources[0]?.value ?? "");
- }
- }, [sourceValue, sources]);
- return {
- const [kind, ref] = sourceValue.split("|");
- await ensureDeviceBinding({
- projectRef: workspace.project.projectRef,
- bindingKey,
- displayName,
- source: { kind: kind as "device" | "collection", ref },
- targetKind,
- targetRef,
- capabilities: commaList(capabilities),
- });
- }}>
-
-
- setDisplayName(event.target.value)} required />
- setTargetKind(event.target.value)} required placeholder="foundry.application" />
- setTargetRef(event.target.value)} required />
- setCapabilities(event.target.value)} description="observe, inspect, configure, command" required />
- ;
-}
-
-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 {
- await upsertProjectGrant({
- projectRef,
- principalKind,
- principalRef,
- projectRole: role,
- capabilityAllow: commaList(allow),
- capabilityDeny: commaList(deny),
- lifecycleState: "active",
- });
- }}>
-
- setPrincipalRef(event.target.value)} required />
- ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} />
- setAllow(event.target.value)} description="Опциональные точечные добавления" />
- setDeny(event.target.value)} description="Deny имеет приоритет" />
- ;
-}
-
-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 {
- const parsed = JSON.parse(configuration) as Record;
- 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,
- });
- }}>
- ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
- setConfiguration(event.target.value)} required />
- setSummary(event.target.value)} />
- Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.
- ;
-}
-
-interface DialogBaseProps {
- open: boolean;
- onClose: () => void;
- onCreated: () => Promise;
- onError: (reason: unknown) => void;
-}
-
-function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & {
- id: string;
- title: string;
- submit: () => Promise;
- disabled?: boolean;
- children: ReactNode;
-}) {
- const [pending, setPending] = useState(false);
- const handleSubmit = async (event: FormEvent) => {
- event.preventDefault();
- setPending(true);
- try {
- await submit();
- await onCreated();
- } catch (reason) {
- onError(reason);
- } finally {
- setPending(false);
- }
- };
- return (
-
- Отмена
- {pending ? "Сохраняем…" : "Сохранить"}
-
- }>
-
-
- );
-}
-
-function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
- return onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />;
-}
-
-function ControlStack({ children }: { children: ReactNode }) {
- return {children}
;
-}
-
-function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) {
- return {copy}
{actions ?
{actions}
: null}
;
-}
-
-function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) {
- return ;
-}
-
-function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) {
- const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
- return hasChildren ? {children}
: {empty}
;
-}
-
-function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
- return {status} {action}>}>
- {meta.length ? {meta.map((item) => {item} )}
: Metadata-only projection
}
- ;
-}
-
-function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
- const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
- return hasChildren ? {children}
: {empty}
;
-}
-
-function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
- return
-
- {title} {description}
- {status} {typeof trailing === "string" ? {trailing} : trailing}
- ;
-}
-
-function PolicyCard({ label, value }: { label: string; value: string }) {
- return {label} {value} ;
-}
-
-function commaList(value: string) {
- return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort();
-}
-
-function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
- if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
- if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
- if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
- return "neutral";
-}
-
-function formatDate(value: string | null) {
- if (!value) return "—";
- return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
-}
-
-function formatBytes(value: number) {
- if (value < 1024) return `${value} B`;
- if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
- return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
-}
-
-function shortRef(value: string | null) {
- return value ? `${value.slice(0, 18)}…` : "—";
-}
-
-function shortDigest(value: string) {
- return `${value.slice(0, 15)}…${value.slice(-8)}`;
-}
diff --git a/apps/device-manager/src/DeviceInventoryView.tsx b/apps/device-manager/src/DeviceInventoryView.tsx
deleted file mode 100644
index 75fa4a6..0000000
--- a/apps/device-manager/src/DeviceInventoryView.tsx
+++ /dev/null
@@ -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;
- onError: (reason: unknown) => void;
-}) {
- const [selectedDeviceRef, setSelectedDeviceRef] = useState(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 (
- setSelectedDeviceRef(null)}
- />
- );
- }
-
- return (
-
-
-
-
Реестр устройств
-
{workspace.devices.length} зарегистрировано в проекте
-
-
-
-
-
-
- {!workspace.devices.length ? (
-
-
- В проекте пока нет устройств
- Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому enrollment-процессу.
-
- ) : (
-
-
- Устройство
- Профиль
- Идентификатор
- Канал
- Последний пакет
-
-
- {workspace.devices.map((device) => {
- const session = latestSession(workspace, device);
- const online = session?.lifecycleState === "online" || device.session?.state === "online";
- return (
-
setSelectedDeviceRef(device.deviceRef)}
- >
-
-
- {device.displayName} {device.deviceKey || "ключ не назначен"}
-
- {profileLabel(workspace, device)}
- {device.identifier?.masked || "не назначен"}
- {online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
- {formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}
-
-
- );
- })}
-
- )}
-
- );
-}
-
-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(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(".nodedc-application-panel__body");
- if (panelBody) panelBody.scrollTop = 0;
- }, [activeSectionId, device.deviceRef]);
-
- if (!activeSection) return null;
-
- return (
-
-
-
-
-
-
-
{catalog.vendor} · {catalog.model}
-
{device.displayName}
-
{device.identifier?.masked || "Идентификатор не назначен"} · {device.modelProfileRef}
-
-
- {session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
-
-
-
-
-
-
-
- {catalog.sections.map((section) => (
- setActiveSectionId(section.id)}
- >
- {section.label}
- {accessLabel(section.access)}
-
- ))}
-
-
-
-
-
-
{catalog.title}
-
{activeSection.title}
-
{activeSection.description}
-
-
-
-
-
-
-
- {activeSection.fields.map((item) => {
- const access = item.access ?? activeSection.access;
- const value = readPath(context, item.path);
- return (
-
-
-
{item.label}
- {access !== activeSection.access ?
: null}
-
-
{formatFieldValue(value, item)}
- {item.description ?
{item.description} : null}
-
- );
- })}
-
-
-
- Desired
- {configurationState?.desiredConfigurationRevisionRef || "Не задано"}
- Applied
- {configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}
-
-
-
-
- );
-}
-
-function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
- const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
- return {accessLabel(access)} ;
-}
-
-function AccessNotice({
- access,
- commandTransport,
-}: {
- access: DeviceFieldAccess;
- commandTransport: ProjectWorkspace["policies"]["commandTransport"];
-}) {
- if (access === "read-only") {
- return Этот блок отражает фактическое состояние устройства и не редактируется.
;
- }
- if (access === "protected") {
- return Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.
;
- }
- return {commandTransport === "typed-service-ping-v1" ? "Настройка управляется через типизированный command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}
;
-}
-
-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((value, key) => {
- if (!value || typeof value !== "object") return undefined;
- return (value as Record)[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;
diff --git a/apps/device-manager/src/DeviceManagerApp.tsx b/apps/device-manager/src/DeviceManagerApp.tsx
deleted file mode 100644
index 50bd4db..0000000
--- a/apps/device-manager/src/DeviceManagerApp.tsx
+++ /dev/null
@@ -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({ navigationOpen: true });
- const [session, setSession] = useState(null);
- const [projects, setProjects] = useState([]);
- const [workspace, setWorkspace] = useState(null);
- const [activeOwnerRef, setActiveOwnerRef] = useState("");
- const [activeProjectRef, setActiveProjectRef] = useState("");
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [projectDialogOpen, setProjectDialogOpen] = useState(false);
- const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
- const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false);
- const [claimEnrollment, setClaimEnrollment] = useState(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 Подключаем Device Core…
;
- }
-
- const activeView = shell.activeView ?? "overview";
- return (
- <>
- }
- brandHref="/"
- center={
-
-
-
- }
- right={
-
-
-
- }
- />
- }
- stage={
- setError(null)}
- />
- }
- navigationOpen={shell.navigationOpen}
- navigation={
- setProjectDialogOpen(true)}
- >
-
-
- }
- contextSlot={
- ownerScopes.length ? (
-
- ({
- value: scope.ownerRef,
- label: scope.displayName,
- description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
- }))}
- onChange={setActiveOwnerRef}
- searchable={ownerScopes.length > 6}
- />
- {visibleProjects.length ? (
- ({
- value: project.projectRef,
- label: project.name,
- description: project.access.projectRole || "read",
- }))}
- onChange={(projectRef) => openProject(projectRef)}
- searchable={visibleProjects.length > 6}
- />
- ) : null}
-
- ) : null
- }
- items={activeProject ? visibleNavigationItems.map((item) => ({
- id: item.id,
- label: item.label,
- icon: ,
- })) : []}
- activeId={activeProject ? activeView : undefined}
- footer={{session.actor.hubRole} · {projects.length} проектов }
- onClose={shell.closeNavigation}
- onItemChange={(id) => shell.openView(id as ViewId)}
- />
- }
- contentOpen={shell.contentOpen}
- contentExpanded={shell.contentExpanded}
- content={
- activeProject ? (
- refreshWorkspace().catch((reason) => setError(errorText(reason))),
- }]}
- >
- setError(errorText(reason))}
- onCreateCollection={() => setCollectionDialogOpen(true)}
- onCreateEnrollment={() => setEnrollmentDialogOpen(true)}
- onClaim={setClaimEnrollment}
- />
-
- ) : null
- }
- />
-
- setProjectDialogOpen(false)}
- onCreated={async () => {
- setProjectDialogOpen(false);
- await refreshProjects();
- }}
- onError={(reason) => setError(errorText(reason))}
- />
- setCollectionDialogOpen(false)}
- onCreated={async () => {
- setCollectionDialogOpen(false);
- await refreshWorkspace();
- }}
- onError={(reason) => setError(errorText(reason))}
- />
- setClaimEnrollment(null)}
- onClaimed={async () => {
- setClaimEnrollment(null);
- await refreshWorkspace();
- }}
- onError={(reason) => setError(errorText(reason))}
- />
- 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 (
-
- {error ? (
-
-
- {error}
- Закрыть
-
- ) : null}
-
-
-
- NODEDC / DEVICE CORE
- Device Core
- Единый контур подключения, учёта и управления устройствами.
-
-
-
-
DEVICE PROJECTS
Рабочие проекты
-
Доступ определяется Hub и project grants
-
- {projects.length ? (
-
- {projects.map((project) => (
- onOpenProject(project.projectRef)}>
-
-
- {project.ownerScope.displayName}
- {project.name}
- {project.description || "Универсальный проект устройств"}
-
-
- {project.counts.devices} устройств
- {project.counts.collections} коллекций
-
-
- {project.access.projectRole || "read"}
-
-
- ))}
-
- ) : (
-
-
- В этом контуре пока нет проектов
- Создание доступно только в owner scope, подтверждённом Hub.
-
- )}
-
-
-
- );
-}
-
-function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) {
- return (
-
- {label}
- {value}
- {detail}
-
- );
-}
-
-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;
- onPoll: () => Promise;
- onError: (reason: unknown) => void;
- onCreateCollection: () => void;
- onCreateEnrollment: () => void;
- onClaim: (enrollment: EnrollmentView) => void;
-}) {
- if (!workspace) return Загружаем проект…
;
- if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
- return ;
- }
- if (view === "inventory") return (
-
- );
- if (view === "discovery") return (
-
-
-
Заранее разрешите конкретный идентификатор на активном маршруте. Core сохранит только HMAC и маску.
-
}
- variant="primary"
- disabled={!canEnroll || !workspace.routes.some((route) => route.lifecycleState === "active")}
- onClick={onCreateEnrollment}
- >
- Подключить устройство
-
-
- {workspace.enrollments.map((enrollment) => (
-
onClaim(enrollment)}>
- Принять устройство
-
- ) : {enrollment.lifecycleState} }
- >
- Enrollment и quarantine evidence остаются связанными; raw identifier в интерфейс не передаётся.
-
- ))}
- {!workspace.enrollments.length ?
Нет ожидающих подключений.
: null}
-
- );
- if (view === "collections") return (
-
-
-
Коллекции группируют устройства для последующих bindings и задач.
-
} disabled={!canManageCollections} onClick={onCreateCollection}>Новая коллекция
-
-
({
- id: collection.collectionRef,
- title: collection.name,
- subtitle: collection.description || collection.collectionKey,
- status: `${collection.memberCount} устройств`,
- tone: "neutral",
- }))}
- />
-
- );
- return (
-
-
-
item.lifecycleState === "quarantine").length} detail="safe projection" />
-
-
-
- {workspace.project.access.capabilities.map((capability) => {capability} )}
-
-
-
- );
-}
-
-function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) {
- if (!items.length) return {empty}
;
- return {items.map((item) => (
-
-
- {item.title} {item.subtitle}
- {item.status}
-
- ))}
;
-}
-
-function ProjectDialog({ open, ownerScopes, initialOwnerRef, onClose, onCreated, onError }: {
- open: boolean;
- ownerScopes: OwnerScopeClaim[];
- initialOwnerRef: string;
- onClose: () => void;
- onCreated: () => Promise;
- 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 Отмена {pending ? "Создаём…" : "Создать"}
- }>
-
- ;
-}
-
-function CollectionDialog({ open, project, onClose, onCreated, onError }: {
- open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise; 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 Отмена {pending ? "Сохраняем…" : "Создать"}
- }>
-
- ;
-}
-
-function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
- enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise; 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 Отмена {pending ? "Проверяем…" : "Принять"}
- }>
-
- ;
-}
-
-function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: {
- open: boolean;
- workspace: ProjectWorkspace | null;
- onClose: () => void;
- onCreated: () => Promise;
- 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
- Отмена
-
- {pending ? "Защищаем идентификатор…" : "Создать enrollment"}
-
-
- }>
-
- ;
-}
-
-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 = {
- device_project_capability_denied: "Недостаточно прав в выбранном проекте.",
- device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.",
- device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.",
- };
- return labels[value] || value;
-}
diff --git a/apps/device-manager/src/api.ts b/apps/device-manager/src/api.ts
deleted file mode 100644
index b432936..0000000
--- a/apps/device-manager/src/api.ts
+++ /dev/null
@@ -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 {
- return requestJson<{ ok: true; session: DeviceManagerSession }>(
- "/api/device-manager/session",
- ).then((value) => value.session);
-}
-
-export async function loadProjects(): Promise {
- return requestJson<{ ok: true; projects: ProjectSummary[] }>(
- "/api/device-manager/projects",
- ).then((value) => value.projects);
-}
-
-export async function loadWorkspace(projectRef: string): Promise {
- 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;
- 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(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(path: string, init?: RequestInit): Promise {
- 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;
-}
diff --git a/apps/device-manager/src/deviceProfileCatalog.ts b/apps/device-manager/src/deviceProfileCatalog.ts
deleted file mode 100644
index d2463a6..0000000
--- a/apps/device-manager/src/deviceProfileCatalog.ts
+++ /dev/null
@@ -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, ...options });
-
-const managed = (
- key: string,
- label: string,
- path: string,
- options: Omit = {},
-) => field(key, label, path, { ...options, access: "managed" });
-
-const protectedField = (
- key: string,
- label: string,
- path: string,
- options: Omit = {},
-) => 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 = {},
-) => 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 = {
- 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)[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, Wi‑Fi, фотоснимки, 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", "Wi‑Fi: имя сети", "reported.configuration.ports.wifi.ssid"),
- managed("wifi-password", "Wi‑Fi: пароль", "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", "Wi‑Fi локатор", "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([
- [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];
-}
diff --git a/apps/device-manager/src/main.tsx b/apps/device-manager/src/main.tsx
deleted file mode 100644
index c7e1457..0000000
--- a/apps/device-manager/src/main.tsx
+++ /dev/null
@@ -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(
-
-
- ,
-);
diff --git a/apps/device-manager/src/styles.css b/apps/device-manager/src/styles.css
deleted file mode 100644
index ecc450d..0000000
--- a/apps/device-manager/src/styles.css
+++ /dev/null
@@ -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;
- }
-}
diff --git a/apps/device-manager/src/types.ts b/apps/device-manager/src/types.ts
deleted file mode 100644
index 1b2f548..0000000
--- a/apps/device-manager/src/types.ts
+++ /dev/null
@@ -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;
- metadata?: Record;
- firmware?: Record;
- configuration?: Record;
- telemetry?: Record;
- diagnostics?: Record;
- operations?: Record;
- } | 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;
- };
-}
diff --git a/apps/device-manager/tsconfig.json b/apps/device-manager/tsconfig.json
deleted file mode 100644
index fd471c7..0000000
--- a/apps/device-manager/tsconfig.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "extends": "../../tsconfig.base.json",
- "compilerOptions": {
- "composite": true,
- "noEmit": true,
- "types": ["vite/client"]
- },
- "include": ["src", "vite.config.ts"]
-}
diff --git a/apps/device-manager/vite.config.ts b/apps/device-manager/vite.config.ts
deleted file mode 100644
index b2f1a56..0000000
--- a/apps/device-manager/vite.config.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-
-export default defineConfig({
- plugins: [react()],
- build: { sourcemap: true },
-});
diff --git a/package-lock.json b/package-lock.json
index 2c6f06d..e58a60b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -43,23 +43,6 @@
"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": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -938,10 +921,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@nodedc/device-manager": {
- "resolved": "apps/device-manager",
- "link": true
- },
"node_modules/@nodedc/map-cesium-react": {
"resolved": "packages/map-cesium-react",
"link": true