refactor: move Device Manager app to Device Core repository
This commit is contained in:
@@ -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.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="theme-color" content="#101114" />
|
|
||||||
<title>NODE.DC Device Core</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@nodedc/device-manager",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite --host 127.0.0.1",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"typecheck": "tsc -b --pretty false",
|
|
||||||
"test": "node --test server/*.test.mjs",
|
|
||||||
"serve": "node server/device-manager-server.mjs"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@nodedc/ui-core": "0.7.0",
|
|
||||||
"@nodedc/ui-react": "0.7.0",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.1.0",
|
|
||||||
"@types/react-dom": "^19.1.0",
|
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "^7.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55"><path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/><polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 315 B |
@@ -1,889 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
|
|
||||||
const commandRoutes = new Map([
|
|
||||||
["owner-scopes:ensure", "/internal/v1/management/owner-scopes:ensure"],
|
|
||||||
["projects:ensure", "/internal/v1/management/projects:ensure"],
|
|
||||||
["collections:ensure", "/internal/v1/management/collections:ensure"],
|
|
||||||
["project-grants:upsert", "/internal/v1/management/project-grants:upsert"],
|
|
||||||
["adapter-packages:ensure", "/internal/v1/management/adapter-packages:ensure"],
|
|
||||||
["adapter-versions:register", "/internal/v1/management/adapter-versions:register"],
|
|
||||||
["model-profiles:register", "/internal/v1/management/model-profiles:register"],
|
|
||||||
["edges:ensure", "/internal/v1/management/edges:ensure"],
|
|
||||||
["routes:ensure", "/internal/v1/management/routes:ensure"],
|
|
||||||
["enrollment-intents:ensure", "/internal/v1/management/enrollment-intents:ensure"],
|
|
||||||
["devices:claim", "/internal/v1/management/devices:claim"],
|
|
||||||
["device-bindings:ensure", "/internal/v1/management/device-bindings:ensure"],
|
|
||||||
["device-bindings:revoke", "/internal/v1/management/device-bindings:revoke"],
|
|
||||||
[
|
|
||||||
"device-configuration-revisions:create",
|
|
||||||
"/internal/v1/management/device-configuration-revisions:create",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"device-configurations:set-desired",
|
|
||||||
"/internal/v1/management/device-configurations:set-desired",
|
|
||||||
],
|
|
||||||
["commands:service-ping", "/internal/v1/commands:service-ping"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
|
||||||
const endpoint = normalizeBaseUrl(baseUrl);
|
|
||||||
if (typeof token !== "string" || token.length < 32) {
|
|
||||||
throw serviceError("device_core_token_invalid", 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(pathname, actor, init = {}) {
|
|
||||||
const response = await fetchImpl(new URL(pathname, endpoint), {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
...actorHeaders(actor),
|
|
||||||
...(init.headers ?? {}),
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(10_000),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
if (!response.ok || body?.ok !== true) {
|
|
||||||
throw serviceError(
|
|
||||||
safeCoreError(body?.error),
|
|
||||||
response.status >= 400 && response.status < 600 ? response.status : 502,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
configured: true,
|
|
||||||
async listProjects(actor) {
|
|
||||||
return request("/internal/v1/query/projects", actor)
|
|
||||||
.then((body) => body.projects);
|
|
||||||
},
|
|
||||||
async getWorkspace(actor, projectRef) {
|
|
||||||
const projectId = entityId(projectRef, "project");
|
|
||||||
return request(`/internal/v1/query/projects/${projectId}/workspace`, actor)
|
|
||||||
.then((body) => body.workspace);
|
|
||||||
},
|
|
||||||
async execute(command, actor, input, idempotencyKey) {
|
|
||||||
const pathname = commandRoutes.get(command);
|
|
||||||
if (!pathname) throw serviceError("device_manager_command_invalid", 404);
|
|
||||||
if (!/^[\x21-\x7e]{8,256}$/.test(idempotencyKey || "")) {
|
|
||||||
throw serviceError("device_idempotency_key_invalid", 400);
|
|
||||||
}
|
|
||||||
return request(pathname, actor, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Idempotency-Key": idempotencyKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
}).then(({ replayed, result }) => ({ replayed, result }));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|
||||||
const ownerScopes = new Map();
|
|
||||||
const projects = new Map();
|
|
||||||
const collections = new Map();
|
|
||||||
const adapterPackages = new Map();
|
|
||||||
const adapterVersions = new Map();
|
|
||||||
const modelProfiles = new Map();
|
|
||||||
const edges = new Map();
|
|
||||||
const routes = new Map();
|
|
||||||
const enrollments = new Map();
|
|
||||||
const devices = new Map();
|
|
||||||
const sessions = new Map();
|
|
||||||
const bindings = new Map();
|
|
||||||
const grants = new Map();
|
|
||||||
const configurationRevisions = new Map();
|
|
||||||
const configurationStates = new Map();
|
|
||||||
const auditEvents = [];
|
|
||||||
const commands = new Map();
|
|
||||||
|
|
||||||
function now() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectValues(store, projectRef) {
|
|
||||||
return [...store.values()].filter((value) => value.projectRef === projectRef);
|
|
||||||
}
|
|
||||||
|
|
||||||
function audit(actor, projectRef, eventType, refs = {}) {
|
|
||||||
auditEvents.unshift({
|
|
||||||
auditEventRef: `audit-event:${randomUUID()}`,
|
|
||||||
eventType,
|
|
||||||
actorRef: actor.userRef,
|
|
||||||
deviceRef: refs.deviceRef ?? null,
|
|
||||||
discoveryRef: refs.discoveryRef ?? null,
|
|
||||||
projectRef,
|
|
||||||
occurredAt: now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectSummary(project) {
|
|
||||||
const projectCollections = [...collections.values()]
|
|
||||||
.filter((collection) => collection.projectRef === project.projectRef);
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
counts: {
|
|
||||||
devices: projectValues(devices, project.projectRef).length,
|
|
||||||
collections: projectCollections.length,
|
|
||||||
discoveries: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspace(projectRef) {
|
|
||||||
const project = projects.get(projectRef);
|
|
||||||
if (!project) throw serviceError("device_project_not_found", 404);
|
|
||||||
return {
|
|
||||||
project: projectSummary(project),
|
|
||||||
devices: projectValues(devices, projectRef)
|
|
||||||
.map(({ projectRef: _projectRef, ...device }) => device),
|
|
||||||
discoveries: [],
|
|
||||||
enrollments: projectValues(enrollments, projectRef),
|
|
||||||
collections: projectValues(collections, projectRef)
|
|
||||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
|
||||||
adapterPackages: [...adapterPackages.values()],
|
|
||||||
adapterVersions: [...adapterVersions.values()],
|
|
||||||
modelProfiles: [...modelProfiles.values()],
|
|
||||||
edges: [...edges.values()],
|
|
||||||
routes: projectValues(routes, projectRef),
|
|
||||||
sessions: projectValues(sessions, projectRef)
|
|
||||||
.map(({ projectRef: _projectRef, ...session }) => session),
|
|
||||||
bindings: projectValues(bindings, projectRef),
|
|
||||||
configurationRevisions: projectValues(configurationRevisions, projectRef),
|
|
||||||
configurationStates: projectValues(configurationStates, projectRef),
|
|
||||||
commands: projectValues(commands, projectRef),
|
|
||||||
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
|
|
||||||
grants: projectValues(grants, projectRef),
|
|
||||||
policies: {
|
|
||||||
commandTransport: fixture === "arusnavi-b2"
|
|
||||||
? "typed-service-ping-v1"
|
|
||||||
: "disabled",
|
|
||||||
commandPlanningApi: fixture === "arusnavi-b2" ? "enabled" : "disabled",
|
|
||||||
identifierProjection: "masked-only",
|
|
||||||
auditPayloadProjection: "metadata-only",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fixture === "arusnavi-b2") seedArusnaviB2Preview({
|
|
||||||
ownerScopes,
|
|
||||||
projects,
|
|
||||||
devices,
|
|
||||||
modelProfiles,
|
|
||||||
edges,
|
|
||||||
routes,
|
|
||||||
sessions,
|
|
||||||
configurationStates,
|
|
||||||
});
|
|
||||||
else if (fixture != null && fixture !== "") {
|
|
||||||
throw serviceError("device_manager_preview_fixture_invalid", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
configured: true,
|
|
||||||
async listProjects() {
|
|
||||||
return [...projects.values()].map(projectSummary);
|
|
||||||
},
|
|
||||||
async getWorkspace(_actor, projectRef) {
|
|
||||||
return workspace(projectRef);
|
|
||||||
},
|
|
||||||
async execute(command, actor, input) {
|
|
||||||
if (command === "commands:service-ping") {
|
|
||||||
if (fixture !== "arusnavi-b2") {
|
|
||||||
throw serviceError("device_command_transport_disabled", 409);
|
|
||||||
}
|
|
||||||
const device = devices.get(input.deviceRef);
|
|
||||||
if (!device || device.projectRef !== input.projectRef) {
|
|
||||||
throw serviceError("device_command_route_unavailable", 409);
|
|
||||||
}
|
|
||||||
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
|
|
||||||
throw serviceError("device_service_ping_access_code_invalid", 400);
|
|
||||||
}
|
|
||||||
const commandRef = `command:${randomUUID()}`;
|
|
||||||
const at = now();
|
|
||||||
const view = {
|
|
||||||
commandRef,
|
|
||||||
projectRef: input.projectRef,
|
|
||||||
deviceRef: input.deviceRef,
|
|
||||||
deviceName: device.displayName,
|
|
||||||
commandKey: `preview-service-ping-${randomUUID()}`,
|
|
||||||
commandCatalogRef: "arusnavi.b2.internal.v1:service-ping",
|
|
||||||
commandType: "service.ping",
|
|
||||||
riskClass: "low",
|
|
||||||
lifecycleState: "queued",
|
|
||||||
plannedAt: at,
|
|
||||||
expiresAt: new Date(Date.now() + Number(input.expiresInSeconds) * 1000).toISOString(),
|
|
||||||
confirmedAt: null,
|
|
||||||
dispatchedAt: null,
|
|
||||||
acknowledgedAt: null,
|
|
||||||
terminalAt: null,
|
|
||||||
terminalReasonCode: null,
|
|
||||||
createdAt: at,
|
|
||||||
updatedAt: at,
|
|
||||||
};
|
|
||||||
commands.set(commandRef, view);
|
|
||||||
return { replayed: false, result: view };
|
|
||||||
}
|
|
||||||
if (command === "owner-scopes:ensure") {
|
|
||||||
const key = `${input.scopeKind}:${input.ownerRef}`;
|
|
||||||
const created = !ownerScopes.has(key);
|
|
||||||
const scope = {
|
|
||||||
ownerScopeRef: ownerScopes.get(key)?.ownerScopeRef || `owner-scope:${randomUUID()}`,
|
|
||||||
scopeKind: input.scopeKind,
|
|
||||||
ownerRef: input.ownerRef,
|
|
||||||
displayName: input.displayName,
|
|
||||||
lifecycleState: "active",
|
|
||||||
};
|
|
||||||
ownerScopes.set(key, scope);
|
|
||||||
return { replayed: false, result: { created, ownerScope: scope } };
|
|
||||||
}
|
|
||||||
if (command === "projects:ensure") {
|
|
||||||
const scope = ownerScopes.get(`${input.scopeKind}:${input.ownerRef}`);
|
|
||||||
if (!scope) throw serviceError("device_owner_scope_not_found", 404);
|
|
||||||
const existing = [...projects.values()].find((project) =>
|
|
||||||
project.ownerScope.ownerRef === input.ownerRef
|
|
||||||
&& project.projectKey === input.projectKey
|
|
||||||
);
|
|
||||||
const projectRef = existing?.projectRef || `project:${randomUUID()}`;
|
|
||||||
const project = {
|
|
||||||
projectRef,
|
|
||||||
projectKey: input.projectKey,
|
|
||||||
name: input.name,
|
|
||||||
description: input.description ?? null,
|
|
||||||
lifecycleState: "active",
|
|
||||||
ownerScope: scope,
|
|
||||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
|
||||||
counts: { devices: 0, collections: 0, discoveries: 0 },
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
projects.set(projectRef, project);
|
|
||||||
if (!existing) {
|
|
||||||
const grantRef = `grant:${randomUUID()}`;
|
|
||||||
grants.set(grantRef, {
|
|
||||||
grantRef,
|
|
||||||
projectRef,
|
|
||||||
principalKind: "user",
|
|
||||||
principalRef: actor.userRef,
|
|
||||||
projectRole: "owner",
|
|
||||||
capabilityAllow: [],
|
|
||||||
capabilityDeny: [],
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
audit(actor, projectRef, "project.created");
|
|
||||||
}
|
|
||||||
return { replayed: false, result: { created: !existing, project } };
|
|
||||||
}
|
|
||||||
if (command === "collections:ensure") {
|
|
||||||
const projectRef = input.projectRef;
|
|
||||||
if (!projects.has(projectRef)) throw serviceError("device_project_not_found", 404);
|
|
||||||
const existing = [...collections.values()].find((collection) =>
|
|
||||||
collection.projectRef === projectRef
|
|
||||||
&& collection.collectionKey === input.collectionKey
|
|
||||||
);
|
|
||||||
const collectionRef = existing?.collectionRef || `collection:${randomUUID()}`;
|
|
||||||
const collection = {
|
|
||||||
collectionRef,
|
|
||||||
projectRef,
|
|
||||||
collectionKey: input.collectionKey,
|
|
||||||
name: input.name,
|
|
||||||
description: input.description ?? null,
|
|
||||||
lifecycleState: "active",
|
|
||||||
memberCount: existing?.memberCount || 0,
|
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
collections.set(collectionRef, collection);
|
|
||||||
audit(actor, projectRef, createdEvent(existing, "collection"));
|
|
||||||
return { replayed: false, result: { created: !existing, collection } };
|
|
||||||
}
|
|
||||||
if (command === "project-grants:upsert") {
|
|
||||||
if (!projects.has(input.projectRef)) {
|
|
||||||
throw serviceError("device_project_not_found", 404);
|
|
||||||
}
|
|
||||||
const existing = [...grants.values()].find((grant) =>
|
|
||||||
grant.projectRef === input.projectRef
|
|
||||||
&& grant.principalKind === input.principalKind
|
|
||||||
&& grant.principalRef === input.principalRef
|
|
||||||
);
|
|
||||||
const grantRef = existing?.grantRef || `grant:${randomUUID()}`;
|
|
||||||
const grant = {
|
|
||||||
grantRef,
|
|
||||||
projectRef: input.projectRef,
|
|
||||||
principalKind: input.principalKind,
|
|
||||||
principalRef: input.principalRef,
|
|
||||||
projectRole: input.projectRole,
|
|
||||||
capabilityAllow: input.capabilityAllow ?? [],
|
|
||||||
capabilityDeny: input.capabilityDeny ?? [],
|
|
||||||
lifecycleState: input.lifecycleState ?? "active",
|
|
||||||
};
|
|
||||||
grants.set(grantRef, grant);
|
|
||||||
audit(actor, input.projectRef, createdEvent(existing, "project_grant"));
|
|
||||||
return { replayed: false, result: { created: !existing, grant } };
|
|
||||||
}
|
|
||||||
if (command === "adapter-packages:ensure") {
|
|
||||||
requirePlatformOwner(actor);
|
|
||||||
const existing = [...adapterPackages.values()].find(
|
|
||||||
(entry) => entry.packageKey === input.packageKey,
|
|
||||||
);
|
|
||||||
const adapterPackageRef = existing?.adapterPackageRef
|
|
||||||
|| `adapter-package:${randomUUID()}`;
|
|
||||||
assertPreviewTransition(
|
|
||||||
existing?.lifecycleState,
|
|
||||||
input.lifecycleState ?? "active",
|
|
||||||
previewTransitions.adapterPackage,
|
|
||||||
"device_adapter_package_transition_invalid",
|
|
||||||
);
|
|
||||||
const adapterPackage = {
|
|
||||||
adapterPackageRef,
|
|
||||||
packageKey: input.packageKey,
|
|
||||||
displayName: input.displayName,
|
|
||||||
publisherRef: input.publisherRef,
|
|
||||||
lifecycleState: input.lifecycleState ?? "active",
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
adapterPackages.set(adapterPackageRef, adapterPackage);
|
|
||||||
return { replayed: false, result: { created: !existing, adapterPackage } };
|
|
||||||
}
|
|
||||||
if (command === "adapter-versions:register") {
|
|
||||||
requirePlatformOwner(actor);
|
|
||||||
const adapterPackage = adapterPackages.get(input.adapterPackageRef);
|
|
||||||
if (!adapterPackage) {
|
|
||||||
throw serviceError("device_adapter_package_not_found", 404);
|
|
||||||
}
|
|
||||||
if (adapterPackage.lifecycleState !== "active") {
|
|
||||||
throw serviceError("device_adapter_package_inactive", 409);
|
|
||||||
}
|
|
||||||
const existing = [...adapterVersions.values()].find((entry) =>
|
|
||||||
entry.adapterPackageRef === input.adapterPackageRef
|
|
||||||
&& entry.version === input.version
|
|
||||||
);
|
|
||||||
const adapterVersionRef = existing?.adapterVersionRef
|
|
||||||
|| `adapter-version:${randomUUID()}`;
|
|
||||||
assertPreviewTransition(
|
|
||||||
existing?.lifecycleState,
|
|
||||||
input.lifecycleState ?? "draft",
|
|
||||||
previewTransitions.catalogVersion,
|
|
||||||
"device_adapter_version_transition_invalid",
|
|
||||||
);
|
|
||||||
const adapterVersion = {
|
|
||||||
adapterVersionRef,
|
|
||||||
adapterPackageRef: input.adapterPackageRef,
|
|
||||||
version: input.version,
|
|
||||||
runtimePackageRef: input.runtimePackageRef,
|
|
||||||
contentDigest: input.contentDigest,
|
|
||||||
contractVersion: input.contractVersion,
|
|
||||||
capabilities: input.capabilities ?? [],
|
|
||||||
lifecycleState: input.lifecycleState ?? "draft",
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
adapterVersions.set(adapterVersionRef, adapterVersion);
|
|
||||||
return { replayed: false, result: { created: !existing, adapterVersion } };
|
|
||||||
}
|
|
||||||
if (command === "model-profiles:register") {
|
|
||||||
requirePlatformOwner(actor);
|
|
||||||
const adapterVersion = adapterVersions.get(input.adapterVersionRef);
|
|
||||||
if (!adapterVersion) {
|
|
||||||
throw serviceError("device_adapter_version_not_found", 404);
|
|
||||||
}
|
|
||||||
const existing = modelProfiles.get(input.profileRef);
|
|
||||||
const lifecycleState = input.lifecycleState ?? "draft";
|
|
||||||
if (lifecycleState === "active" && adapterVersion.lifecycleState !== "active") {
|
|
||||||
throw serviceError("device_model_profile_adapter_not_active", 409);
|
|
||||||
}
|
|
||||||
assertPreviewTransition(
|
|
||||||
existing?.lifecycleState,
|
|
||||||
lifecycleState,
|
|
||||||
previewTransitions.catalogVersion,
|
|
||||||
"device_model_profile_transition_invalid",
|
|
||||||
);
|
|
||||||
const modelProfile = {
|
|
||||||
modelProfileRef: input.profileRef,
|
|
||||||
adapterVersionRef: input.adapterVersionRef,
|
|
||||||
schemaVersion: input.schemaVersion,
|
|
||||||
vendor: input.vendor,
|
|
||||||
model: input.model,
|
|
||||||
deviceType: input.deviceType,
|
|
||||||
protocol: input.protocol,
|
|
||||||
schemaArtifactRef: input.schemaArtifactRef,
|
|
||||||
profileDigest: input.profileDigest,
|
|
||||||
capabilities: input.capabilities ?? [],
|
|
||||||
lifecycleState,
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
modelProfiles.set(input.profileRef, modelProfile);
|
|
||||||
return { replayed: false, result: { created: !existing, modelProfile } };
|
|
||||||
}
|
|
||||||
if (command === "edges:ensure") {
|
|
||||||
requirePlatformOwner(actor);
|
|
||||||
const existing = [...edges.values()].find(
|
|
||||||
(entry) => entry.edgeKey === input.edgeKey,
|
|
||||||
);
|
|
||||||
const edgeRef = existing?.edgeRef || `edge:${randomUUID()}`;
|
|
||||||
assertPreviewTransition(
|
|
||||||
existing?.lifecycleState,
|
|
||||||
input.lifecycleState ?? "provisioning",
|
|
||||||
previewTransitions.edge,
|
|
||||||
"device_edge_transition_invalid",
|
|
||||||
);
|
|
||||||
const edge = {
|
|
||||||
edgeRef,
|
|
||||||
edgeKey: input.edgeKey,
|
|
||||||
displayName: input.displayName,
|
|
||||||
deploymentRef: input.deploymentRef ?? null,
|
|
||||||
lifecycleState: input.lifecycleState ?? "provisioning",
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
edges.set(edgeRef, edge);
|
|
||||||
return { replayed: false, result: { created: !existing, edge } };
|
|
||||||
}
|
|
||||||
if (command === "routes:ensure") {
|
|
||||||
if (!projects.has(input.projectRef)) {
|
|
||||||
throw serviceError("device_project_not_found", 404);
|
|
||||||
}
|
|
||||||
const edge = edges.get(input.edgeRef);
|
|
||||||
const profile = modelProfiles.get(input.modelProfileRef);
|
|
||||||
if (!edge) throw serviceError("device_edge_not_found", 404);
|
|
||||||
if (!profile) throw serviceError("device_model_profile_not_found", 404);
|
|
||||||
const existing = projectValues(routes, input.projectRef).find(
|
|
||||||
(entry) => entry.routeKey === input.routeKey,
|
|
||||||
);
|
|
||||||
const routeRef = existing?.routeRef || `route:${randomUUID()}`;
|
|
||||||
const lifecycleState = input.lifecycleState ?? "draft";
|
|
||||||
if (
|
|
||||||
lifecycleState === "active"
|
|
||||||
&& (edge.lifecycleState !== "active" || profile.lifecycleState !== "active")
|
|
||||||
) {
|
|
||||||
throw serviceError("device_route_dependency_not_active", 409);
|
|
||||||
}
|
|
||||||
assertPreviewTransition(
|
|
||||||
existing?.lifecycleState,
|
|
||||||
lifecycleState,
|
|
||||||
previewTransitions.route,
|
|
||||||
"device_route_transition_invalid",
|
|
||||||
);
|
|
||||||
const route = {
|
|
||||||
routeRef,
|
|
||||||
projectRef: input.projectRef,
|
|
||||||
routeKey: input.routeKey,
|
|
||||||
displayName: input.displayName,
|
|
||||||
edgeRef: input.edgeRef,
|
|
||||||
edgeName: edge.displayName,
|
|
||||||
modelProfileRef: input.modelProfileRef,
|
|
||||||
profileName: `${profile.vendor} ${profile.model}`,
|
|
||||||
listenerRef: input.listenerRef,
|
|
||||||
protocol: input.protocol,
|
|
||||||
direction: input.direction ?? "telemetry",
|
|
||||||
lifecycleState,
|
|
||||||
sessionCount: 0,
|
|
||||||
activeSessionCount: 0,
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
routes.set(routeRef, route);
|
|
||||||
audit(actor, input.projectRef, createdEvent(existing, "route"));
|
|
||||||
return { replayed: false, result: { created: !existing, route } };
|
|
||||||
}
|
|
||||||
if (command === "device-bindings:ensure") {
|
|
||||||
if (!projects.has(input.projectRef)) {
|
|
||||||
throw serviceError("device_project_not_found", 404);
|
|
||||||
}
|
|
||||||
if (input.source?.kind !== "collection" || !collections.has(input.source.ref)) {
|
|
||||||
throw serviceError("device_binding_source_not_found", 404);
|
|
||||||
}
|
|
||||||
const existing = projectValues(bindings, input.projectRef).find(
|
|
||||||
(entry) => entry.bindingKey === input.bindingKey,
|
|
||||||
);
|
|
||||||
const bindingRef = existing?.bindingRef || `binding:${randomUUID()}`;
|
|
||||||
const source = collections.get(input.source.ref);
|
|
||||||
const binding = {
|
|
||||||
bindingRef,
|
|
||||||
projectRef: input.projectRef,
|
|
||||||
bindingKey: input.bindingKey,
|
|
||||||
displayName: input.displayName,
|
|
||||||
source: {
|
|
||||||
kind: input.source.kind,
|
|
||||||
ref: input.source.ref,
|
|
||||||
displayName: source.name,
|
|
||||||
},
|
|
||||||
target: { kind: input.targetKind, ref: input.targetRef },
|
|
||||||
capabilities: input.capabilities,
|
|
||||||
lifecycleState: "pending_external_approval",
|
|
||||||
sourceApprovedAt: now(),
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
bindings.set(bindingRef, binding);
|
|
||||||
audit(actor, input.projectRef, createdEvent(existing, "device_binding"));
|
|
||||||
return { replayed: false, result: { created: !existing, binding } };
|
|
||||||
}
|
|
||||||
if (command === "device-bindings:revoke") {
|
|
||||||
const binding = bindings.get(input.bindingRef);
|
|
||||||
if (!binding || binding.projectRef !== input.projectRef) {
|
|
||||||
throw serviceError("device_binding_not_found", 404);
|
|
||||||
}
|
|
||||||
const revoked = { ...binding, lifecycleState: "revoked", updatedAt: now() };
|
|
||||||
bindings.set(binding.bindingRef, revoked);
|
|
||||||
audit(actor, input.projectRef, "device_binding.revoked");
|
|
||||||
return { replayed: false, result: { revoked: true, binding: revoked } };
|
|
||||||
}
|
|
||||||
if (command === "device-configuration-revisions:create") {
|
|
||||||
throw serviceError("device_not_found", 404);
|
|
||||||
}
|
|
||||||
if (command === "device-configurations:set-desired") {
|
|
||||||
throw serviceError("device_configuration_revision_not_found", 404);
|
|
||||||
}
|
|
||||||
if (command === "enrollment-intents:ensure") {
|
|
||||||
if (!projects.has(input.projectRef)) {
|
|
||||||
throw serviceError("device_project_not_found", 404);
|
|
||||||
}
|
|
||||||
const route = routes.get(input.routeRef);
|
|
||||||
if (!route || route.projectRef !== input.projectRef) {
|
|
||||||
throw serviceError("device_route_not_found", 404);
|
|
||||||
}
|
|
||||||
if (route.lifecycleState !== "active") {
|
|
||||||
throw serviceError("device_enrollment_route_inactive", 409);
|
|
||||||
}
|
|
||||||
if (route.modelProfileRef !== input.modelProfileRef) {
|
|
||||||
throw serviceError("device_enrollment_profile_mismatch", 409);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
input.identifier?.kind !== "imei"
|
|
||||||
|| typeof input.identifier.value !== "string"
|
|
||||||
|| !/^\d{15}$/.test(input.identifier.value)
|
|
||||||
) {
|
|
||||||
throw serviceError("restricted_identifier_imei_invalid", 400);
|
|
||||||
}
|
|
||||||
const existing = projectValues(enrollments, input.projectRef).find(
|
|
||||||
(entry) => entry.enrollmentKey === input.enrollmentKey,
|
|
||||||
);
|
|
||||||
const enrollmentIntentRef = existing?.enrollmentIntentRef
|
|
||||||
|| `enrollment-intent:${randomUUID()}`;
|
|
||||||
const enrollment = {
|
|
||||||
enrollmentIntentRef,
|
|
||||||
projectRef: input.projectRef,
|
|
||||||
enrollmentKey: input.enrollmentKey,
|
|
||||||
displayName: input.displayName,
|
|
||||||
routeRef: input.routeRef,
|
|
||||||
modelProfileRef: input.modelProfileRef,
|
|
||||||
expectedIdentifier: {
|
|
||||||
kind: "imei",
|
|
||||||
masked: `***********${input.identifier.value.slice(-4)}`,
|
|
||||||
},
|
|
||||||
lifecycleState: "pending",
|
|
||||||
observedDiscoveryRef: null,
|
|
||||||
claimedDeviceRef: null,
|
|
||||||
expiresAt: input.expiresAt ?? null,
|
|
||||||
createdAt: existing?.createdAt || now(),
|
|
||||||
updatedAt: now(),
|
|
||||||
};
|
|
||||||
enrollments.set(enrollmentIntentRef, enrollment);
|
|
||||||
audit(actor, input.projectRef, createdEvent(existing, "enrollment_intent"));
|
|
||||||
const { expectedIdentifier, ...safeEnrollment } = enrollment;
|
|
||||||
return {
|
|
||||||
replayed: false,
|
|
||||||
result: {
|
|
||||||
created: !existing,
|
|
||||||
enrollmentIntent: {
|
|
||||||
...safeEnrollment,
|
|
||||||
identifier: expectedIdentifier,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (command === "devices:claim") {
|
|
||||||
throw serviceError("device_discovery_not_found", 404);
|
|
||||||
}
|
|
||||||
throw serviceError("device_manager_command_invalid", 404);
|
|
||||||
},
|
|
||||||
snapshot() {
|
|
||||||
return {
|
|
||||||
ownerScopes,
|
|
||||||
projects,
|
|
||||||
collections,
|
|
||||||
adapterPackages,
|
|
||||||
adapterVersions,
|
|
||||||
modelProfiles,
|
|
||||||
edges,
|
|
||||||
routes,
|
|
||||||
enrollments,
|
|
||||||
devices,
|
|
||||||
sessions,
|
|
||||||
bindings,
|
|
||||||
grants,
|
|
||||||
configurationRevisions,
|
|
||||||
configurationStates,
|
|
||||||
commands,
|
|
||||||
auditEvents,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createdEvent(existing, resource) {
|
|
||||||
return `${resource}.${existing ? "updated" : "created"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirePlatformOwner(actor) {
|
|
||||||
if (actor?.hubRole !== "owner") {
|
|
||||||
throw serviceError("device_platform_catalog_access_denied", 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedArusnaviB2Preview({
|
|
||||||
ownerScopes,
|
|
||||||
projects,
|
|
||||||
devices,
|
|
||||||
modelProfiles,
|
|
||||||
edges,
|
|
||||||
routes,
|
|
||||||
sessions,
|
|
||||||
configurationStates,
|
|
||||||
}) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
|
|
||||||
const projectRef = "project:ad7b357c-c7ac-4bf8-a638-c7f956e9aa71";
|
|
||||||
const deviceRef = "device:b6a55921-7888-44b5-a93e-241aa2fdd3d7";
|
|
||||||
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
|
|
||||||
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
|
|
||||||
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
|
|
||||||
const scope = {
|
|
||||||
ownerScopeRef,
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: "user:local-device-admin",
|
|
||||||
displayName: "Local Device Admin",
|
|
||||||
lifecycleState: "active",
|
|
||||||
};
|
|
||||||
ownerScopes.set("personal:user:local-device-admin", scope);
|
|
||||||
projects.set(projectRef, {
|
|
||||||
projectRef,
|
|
||||||
projectKey: "arusnavi-b2-preview",
|
|
||||||
name: "ARUSNAVI B2 preview",
|
|
||||||
description: "Локальная визуальная фикстура без реальных идентификаторов и команд",
|
|
||||||
lifecycleState: "active",
|
|
||||||
ownerScope: scope,
|
|
||||||
access: { projectRole: "owner", capabilities: ownerCapabilities },
|
|
||||||
counts: { devices: 1, collections: 0, discoveries: 0 },
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
modelProfiles.set("arusnavi.b2.internal.v1", {
|
|
||||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
|
||||||
adapterVersionRef: null,
|
|
||||||
schemaVersion: "1.0.0",
|
|
||||||
vendor: "ARUSNAVI",
|
|
||||||
model: "B2",
|
|
||||||
deviceType: "tracker",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
schemaArtifactRef: "schema:arusnavi.b2.internal.v1",
|
|
||||||
profileDigest: null,
|
|
||||||
capabilities: ["telemetry", "configuration", "commands"],
|
|
||||||
lifecycleState: "active",
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
edges.set(edgeRef, {
|
|
||||||
edgeRef,
|
|
||||||
edgeKey: "preview-edge",
|
|
||||||
displayName: "Preview VPS edge",
|
|
||||||
deploymentRef: "deployment:preview",
|
|
||||||
lifecycleState: "active",
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
routes.set(routeRef, {
|
|
||||||
routeRef,
|
|
||||||
projectRef,
|
|
||||||
routeKey: "preview-b2-route",
|
|
||||||
displayName: "B2 direct preview",
|
|
||||||
edgeRef,
|
|
||||||
edgeName: "Preview VPS edge",
|
|
||||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
|
||||||
profileName: "ARUSNAVI B2",
|
|
||||||
listenerRef: "listener:preview",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
direction: "bidirectional",
|
|
||||||
lifecycleState: "active",
|
|
||||||
sessionCount: 1,
|
|
||||||
activeSessionCount: 1,
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
devices.set(deviceRef, {
|
|
||||||
projectRef,
|
|
||||||
deviceRef,
|
|
||||||
deviceKey: "pilot-b2-preview",
|
|
||||||
displayName: "Пилотный B2",
|
|
||||||
modelProfileRef: "arusnavi.b2.internal.v1",
|
|
||||||
lifecycleState: "active",
|
|
||||||
identifier: { kind: "imei", masked: "***********0001" },
|
|
||||||
session: { state: "online", lastSeenAt: timestamp },
|
|
||||||
reported: {
|
|
||||||
observedAt: timestamp,
|
|
||||||
identity: { iccid1: "****************1111", iccid2: "****************2222" },
|
|
||||||
firmware: { currentVersion: "0.02", appliedAt: timestamp, availableVersion: "0.05" },
|
|
||||||
configuration: {
|
|
||||||
monitoring: {
|
|
||||||
servers: [
|
|
||||||
{ host: "legacy.example.invalid", port: 20623, protocol: "INTERNAL", identity: "0" },
|
|
||||||
{ host: "direct.example.invalid", port: 9921, protocol: "INTERNAL", identity: "0" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
transmission: { navigation: { position: true, motion: true, hdop: false } },
|
|
||||||
trajectory: {
|
|
||||||
normal: { courseDeltaDegrees: 15, speedDeltaKph: 10, distanceMeters: 15, parkingIntervalSeconds: 15 },
|
|
||||||
roaming: { courseDeltaDegrees: 20, speedDeltaKph: 50, distanceMeters: 1000, parkingIntervalSeconds: 300 },
|
|
||||||
},
|
|
||||||
navigation: {
|
|
||||||
sources: { satellite: true, wifi: false, lbs: false, tag: false },
|
|
||||||
constellations: { gps: true, glonass: true, galileo: false, beidou: false },
|
|
||||||
filter: { minimumSatellites: 4, maximumHdopTimesTen: 30 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
telemetry: {
|
|
||||||
navigation: {
|
|
||||||
latitude: "55.7500",
|
|
||||||
longitude: "37.6200",
|
|
||||||
speedKph: 18,
|
|
||||||
altitudeMeters: 156,
|
|
||||||
satellites: 12,
|
|
||||||
courseDegrees: 84,
|
|
||||||
hdop: 1.2,
|
|
||||||
},
|
|
||||||
gsm: { signal: 79, operator: "preview", lac: "masked", cid: "masked" },
|
|
||||||
system: { externalVoltageMv: 13240, internalVoltageMv: 4120, status: "Норма" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: timestamp,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
sessions.set(sessionRef, {
|
|
||||||
sessionRef,
|
|
||||||
projectRef,
|
|
||||||
routeRef,
|
|
||||||
routeName: "B2 direct preview",
|
|
||||||
deviceRef,
|
|
||||||
deviceName: "Пилотный B2",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
lifecycleState: "online",
|
|
||||||
connectedAt: timestamp,
|
|
||||||
lastSeenAt: timestamp,
|
|
||||||
disconnectedAt: null,
|
|
||||||
closeReasonCode: null,
|
|
||||||
frameCount: 1842,
|
|
||||||
byteCount: 734208,
|
|
||||||
});
|
|
||||||
configurationStates.set(deviceRef, {
|
|
||||||
projectRef,
|
|
||||||
deviceRef,
|
|
||||||
deviceName: "Пилотный B2",
|
|
||||||
desiredConfigurationRevisionRef: null,
|
|
||||||
appliedConfigurationRevisionRef: null,
|
|
||||||
appliedAt: null,
|
|
||||||
updatedAt: timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const previewTransitions = Object.freeze({
|
|
||||||
adapterPackage: Object.freeze({
|
|
||||||
active: Object.freeze(["active", "retired"]),
|
|
||||||
retired: Object.freeze(["retired"]),
|
|
||||||
}),
|
|
||||||
catalogVersion: Object.freeze({
|
|
||||||
draft: Object.freeze(["draft", "active", "retired"]),
|
|
||||||
active: Object.freeze(["active", "retired"]),
|
|
||||||
retired: Object.freeze(["retired"]),
|
|
||||||
}),
|
|
||||||
edge: Object.freeze({
|
|
||||||
provisioning: Object.freeze(["provisioning", "active", "retired"]),
|
|
||||||
active: Object.freeze(["active", "suspended", "retired"]),
|
|
||||||
suspended: Object.freeze(["suspended", "active", "retired"]),
|
|
||||||
retired: Object.freeze(["retired"]),
|
|
||||||
}),
|
|
||||||
route: Object.freeze({
|
|
||||||
draft: Object.freeze(["draft", "active", "retired"]),
|
|
||||||
active: Object.freeze(["active", "suspended", "retired"]),
|
|
||||||
suspended: Object.freeze(["suspended", "active", "retired"]),
|
|
||||||
retired: Object.freeze(["retired"]),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
function assertPreviewTransition(previous, next, transitions, code) {
|
|
||||||
if (!previous) return;
|
|
||||||
if (!transitions[previous]?.includes(next)) {
|
|
||||||
throw serviceError(code, 409);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerCapabilities = Object.freeze([
|
|
||||||
"project.read",
|
|
||||||
"project.manage",
|
|
||||||
"access.manage",
|
|
||||||
"inventory.read",
|
|
||||||
"device.enroll",
|
|
||||||
"device.claim",
|
|
||||||
"device.transfer",
|
|
||||||
"collection.manage",
|
|
||||||
"route.manage",
|
|
||||||
"binding.manage",
|
|
||||||
"telemetry.observe",
|
|
||||||
"configuration.read",
|
|
||||||
"configuration.manage",
|
|
||||||
"command.plan",
|
|
||||||
"command.confirm",
|
|
||||||
"command.dispatch",
|
|
||||||
"credential.manage",
|
|
||||||
"audit.read",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function actorHeaders(actor) {
|
|
||||||
if (!actor || typeof actor !== "object") throw serviceError("device_actor_required", 401);
|
|
||||||
return {
|
|
||||||
"X-NODEDC-User-Ref": actor.userRef,
|
|
||||||
"X-NODEDC-Hub-Role": actor.hubRole,
|
|
||||||
"X-NODEDC-Group-Refs": (actor.groupRefs ?? []).join(","),
|
|
||||||
"X-NODEDC-Owner-Scopes": (actor.ownerScopes ?? [])
|
|
||||||
.map((scope) => `${scope.scopeKind}=${scope.ownerRef}`)
|
|
||||||
.join(","),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function entityId(value, prefix) {
|
|
||||||
const match = String(value || "").match(new RegExp(
|
|
||||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
|
||||||
"i",
|
|
||||||
));
|
|
||||||
if (!match) throw serviceError(`device_${prefix}_ref_invalid`, 400);
|
|
||||||
return match[1].toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBaseUrl(value) {
|
|
||||||
if (typeof value !== "string" || value.trim() === "") {
|
|
||||||
throw serviceError("device_core_url_required", 503);
|
|
||||||
}
|
|
||||||
const url = new URL(value);
|
|
||||||
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
|
|
||||||
throw serviceError("device_core_url_invalid", 503);
|
|
||||||
}
|
|
||||||
url.pathname = url.pathname.replace(/\/$/, "") || "/";
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeCoreError(value) {
|
|
||||||
return typeof value === "string" && /^device_[a-z0-9._:-]{2,120}$/.test(value)
|
|
||||||
? value
|
|
||||||
: "device_core_unavailable";
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceError(code, statusCode) {
|
|
||||||
const error = new Error(code);
|
|
||||||
error.statusCode = statusCode;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createDeviceCoreClient,
|
|
||||||
createLocalPreviewDeviceCore,
|
|
||||||
} from "./device-core-client.mjs";
|
|
||||||
|
|
||||||
const token = "device-core-test-token-that-is-never-exposed";
|
|
||||||
const actor = Object.freeze({
|
|
||||||
userRef: "user:device-admin",
|
|
||||||
hubRole: "admin",
|
|
||||||
groupRefs: ["group:device-engineers"],
|
|
||||||
ownerScopes: [{ scopeKind: "personal", ownerRef: "user:device-admin" }],
|
|
||||||
});
|
|
||||||
const platformActor = Object.freeze({ ...actor, hubRole: "owner" });
|
|
||||||
|
|
||||||
test("Device Core client creates trusted actor headers and keeps its token server-side", async () => {
|
|
||||||
const calls = [];
|
|
||||||
const client = createDeviceCoreClient({
|
|
||||||
baseUrl: "http://device-control-core:3210",
|
|
||||||
token,
|
|
||||||
fetchImpl: async (url, init) => {
|
|
||||||
calls.push({ url: String(url), init });
|
|
||||||
return jsonResponse(200, { ok: true, projects: [] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(await client.listProjects(actor), []);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${token}`);
|
|
||||||
assert.equal(calls[0].init.headers["X-NODEDC-User-Ref"], actor.userRef);
|
|
||||||
assert.equal(calls[0].init.headers["X-NODEDC-Hub-Role"], "admin");
|
|
||||||
assert.equal(calls[0].init.headers["X-NODEDC-Group-Refs"], "group:device-engineers");
|
|
||||||
assert.equal(
|
|
||||||
calls[0].init.headers["X-NODEDC-Owner-Scopes"],
|
|
||||||
"personal=user:device-admin",
|
|
||||||
);
|
|
||||||
assert.equal(JSON.stringify(await client.listProjects(actor)).includes(token), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Device Core client accepts only canonical commands and entity refs", async () => {
|
|
||||||
const client = createDeviceCoreClient({
|
|
||||||
baseUrl: "http://127.0.0.1:3210",
|
|
||||||
token,
|
|
||||||
fetchImpl: async () => jsonResponse(200, { ok: true, replayed: false, result: {} }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(
|
|
||||||
client.execute("raw:proxy", actor, {}, "device-manager-12345678"),
|
|
||||||
/device_manager_command_invalid/,
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
client.getWorkspace(actor, "project:not-a-uuid"),
|
|
||||||
/device_project_ref_invalid/,
|
|
||||||
);
|
|
||||||
await assert.rejects(
|
|
||||||
client.execute("projects:ensure", actor, {}, "short"),
|
|
||||||
/device_idempotency_key_invalid/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("local preview is empty and creates resources only through canonical commands", async () => {
|
|
||||||
const client = createLocalPreviewDeviceCore();
|
|
||||||
assert.deepEqual(await client.listProjects(actor), []);
|
|
||||||
|
|
||||||
const owner = await client.execute("owner-scopes:ensure", actor, {
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: actor.userRef,
|
|
||||||
displayName: "Device Admin",
|
|
||||||
});
|
|
||||||
assert.equal(owner.result.created, true);
|
|
||||||
|
|
||||||
const created = await client.execute("projects:ensure", actor, {
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: actor.userRef,
|
|
||||||
projectKey: "sandbox",
|
|
||||||
name: "Device sandbox",
|
|
||||||
description: null,
|
|
||||||
});
|
|
||||||
assert.equal(created.result.created, true);
|
|
||||||
const projectRef = created.result.project.projectRef;
|
|
||||||
|
|
||||||
await client.execute("collections:ensure", actor, {
|
|
||||||
projectRef,
|
|
||||||
collectionKey: "field-devices",
|
|
||||||
name: "Field devices",
|
|
||||||
description: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const adapterPackage = await client.execute("adapter-packages:ensure", platformActor, {
|
|
||||||
packageKey: "generic-tracker",
|
|
||||||
displayName: "Generic tracker",
|
|
||||||
publisherRef: "publisher:nodedc",
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
const adapterVersion = await client.execute("adapter-versions:register", platformActor, {
|
|
||||||
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
|
|
||||||
version: "1.0.0",
|
|
||||||
runtimePackageRef: "artifact:generic-tracker:1.0.0",
|
|
||||||
contentDigest: `sha256:${"a".repeat(64)}`,
|
|
||||||
contractVersion: "device-adapter.v1",
|
|
||||||
capabilities: ["telemetry"],
|
|
||||||
lifecycleState: "draft",
|
|
||||||
});
|
|
||||||
const modelProfile = await client.execute("model-profiles:register", platformActor, {
|
|
||||||
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
|
|
||||||
profileRef: "generic.tracker.v1",
|
|
||||||
schemaVersion: "1.0.0",
|
|
||||||
vendor: "Generic",
|
|
||||||
model: "Tracker",
|
|
||||||
deviceType: "tracker",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
schemaArtifactRef: "schema:generic.tracker.v1",
|
|
||||||
profileDigest: `sha256:${"b".repeat(64)}`,
|
|
||||||
capabilities: ["telemetry"],
|
|
||||||
lifecycleState: "draft",
|
|
||||||
});
|
|
||||||
const edge = await client.execute("edges:ensure", platformActor, {
|
|
||||||
edgeKey: "preview-edge",
|
|
||||||
displayName: "Preview Edge",
|
|
||||||
deploymentRef: "deployment:preview-edge",
|
|
||||||
lifecycleState: "provisioning",
|
|
||||||
});
|
|
||||||
const route = await client.execute("routes:ensure", actor, {
|
|
||||||
projectRef,
|
|
||||||
routeKey: "preview-route",
|
|
||||||
displayName: "Preview route",
|
|
||||||
edgeRef: edge.result.edge.edgeRef,
|
|
||||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
|
||||||
listenerRef: "listener:preview",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
direction: "telemetry",
|
|
||||||
lifecycleState: "draft",
|
|
||||||
});
|
|
||||||
await client.execute("adapter-versions:register", platformActor, {
|
|
||||||
adapterPackageRef: adapterPackage.result.adapterPackage.adapterPackageRef,
|
|
||||||
version: "1.0.0",
|
|
||||||
runtimePackageRef: "artifact:generic-tracker:1.0.0",
|
|
||||||
contentDigest: `sha256:${"a".repeat(64)}`,
|
|
||||||
contractVersion: "device-adapter.v1",
|
|
||||||
capabilities: ["telemetry"],
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
await client.execute("model-profiles:register", platformActor, {
|
|
||||||
adapterVersionRef: adapterVersion.result.adapterVersion.adapterVersionRef,
|
|
||||||
profileRef: "generic.tracker.v1",
|
|
||||||
schemaVersion: "1.0.0",
|
|
||||||
vendor: "Generic",
|
|
||||||
model: "Tracker",
|
|
||||||
deviceType: "tracker",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
schemaArtifactRef: "schema:generic.tracker.v1",
|
|
||||||
profileDigest: `sha256:${"b".repeat(64)}`,
|
|
||||||
capabilities: ["telemetry"],
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
await client.execute("edges:ensure", platformActor, {
|
|
||||||
edgeKey: "preview-edge",
|
|
||||||
displayName: "Preview Edge",
|
|
||||||
deploymentRef: "deployment:preview-edge",
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
await client.execute("routes:ensure", actor, {
|
|
||||||
projectRef,
|
|
||||||
routeKey: "preview-route",
|
|
||||||
displayName: "Preview route",
|
|
||||||
edgeRef: edge.result.edge.edgeRef,
|
|
||||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
|
||||||
listenerRef: "listener:preview",
|
|
||||||
protocol: "INTERNAL",
|
|
||||||
direction: "telemetry",
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
const enrollment = await client.execute("enrollment-intents:ensure", actor, {
|
|
||||||
projectRef,
|
|
||||||
enrollmentKey: "preview-device",
|
|
||||||
routeRef: route.result.route.routeRef,
|
|
||||||
modelProfileRef: modelProfile.result.modelProfile.modelProfileRef,
|
|
||||||
displayName: "Preview device",
|
|
||||||
identifier: { kind: "imei", value: "123456789012345" },
|
|
||||||
expiresAt: null,
|
|
||||||
});
|
|
||||||
assert.equal(enrollment.result.enrollmentIntent.identifier.masked, "***********2345");
|
|
||||||
assert.equal(JSON.stringify(enrollment).includes("123456789012345"), false);
|
|
||||||
const collectionRef = (await client.getWorkspace(actor, projectRef))
|
|
||||||
.collections[0].collectionRef;
|
|
||||||
await client.execute("device-bindings:ensure", actor, {
|
|
||||||
projectRef,
|
|
||||||
bindingKey: "preview-binding",
|
|
||||||
displayName: "Preview binding",
|
|
||||||
source: { kind: "collection", ref: collectionRef },
|
|
||||||
targetKind: "foundry.application",
|
|
||||||
targetRef: "application:preview-map",
|
|
||||||
capabilities: ["observe"],
|
|
||||||
});
|
|
||||||
await client.execute("project-grants:upsert", actor, {
|
|
||||||
projectRef,
|
|
||||||
principalKind: "group",
|
|
||||||
principalRef: "group:preview-viewers",
|
|
||||||
projectRole: "viewer",
|
|
||||||
capabilityAllow: [],
|
|
||||||
capabilityDeny: [],
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
|
|
||||||
const projects = await client.listProjects(actor);
|
|
||||||
assert.equal(projects.length, 1);
|
|
||||||
assert.equal(projects[0].counts.collections, 1);
|
|
||||||
const workspace = await client.getWorkspace(actor, projectRef);
|
|
||||||
assert.equal(workspace.collections[0].collectionKey, "field-devices");
|
|
||||||
assert.equal(workspace.adapterPackages[0].packageKey, "generic-tracker");
|
|
||||||
assert.equal(workspace.routes[0].routeKey, "preview-route");
|
|
||||||
assert.equal(workspace.routes[0].lifecycleState, "active");
|
|
||||||
assert.equal(workspace.enrollments[0].expectedIdentifier.masked, "***********2345");
|
|
||||||
assert.equal(workspace.bindings[0].lifecycleState, "pending_external_approval");
|
|
||||||
assert.equal(workspace.grants.length, 2);
|
|
||||||
assert.ok(workspace.auditEvents.some((event) => event.eventType === "device_binding.created"));
|
|
||||||
assert.equal(workspace.policies.commandTransport, "disabled");
|
|
||||||
assert.deepEqual(workspace.devices, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("explicit B2 preview fixture is isolated from the empty canonical preview", async () => {
|
|
||||||
const client = createLocalPreviewDeviceCore({ fixture: "arusnavi-b2" });
|
|
||||||
const projects = await client.listProjects(actor);
|
|
||||||
assert.equal(projects.length, 1);
|
|
||||||
assert.equal(projects[0].counts.devices, 1);
|
|
||||||
const workspace = await client.getWorkspace(actor, projects[0].projectRef);
|
|
||||||
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
|
|
||||||
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
|
|
||||||
assert.equal(workspace.sessions[0].lifecycleState, "online");
|
|
||||||
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
|
|
||||||
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => createLocalPreviewDeviceCore({ fixture: "unknown" }),
|
|
||||||
/device_manager_preview_fixture_invalid/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function jsonResponse(status, body) {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
|
||||||
|
|
||||||
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
||||||
const DEFAULT_VALIDATION_TTL_MS = 20_000;
|
|
||||||
const DEFAULT_VALIDATION_GRACE_MS = 30_000;
|
|
||||||
|
|
||||||
export function createDeviceManagerAuth({
|
|
||||||
env = process.env,
|
|
||||||
fetchImpl = fetch,
|
|
||||||
now = Date.now,
|
|
||||||
internalToken: providedInternalToken,
|
|
||||||
} = {}) {
|
|
||||||
const authRequired = booleanValue(
|
|
||||||
env.NODEDC_DEVICE_MANAGER_AUTH_REQUIRED,
|
|
||||||
env.NODE_ENV === "production",
|
|
||||||
);
|
|
||||||
const serviceSlug = textValue(env.NODEDC_DEVICE_MANAGER_SERVICE_SLUG, "device-core");
|
|
||||||
const launcherBaseUrl = baseUrl(env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
|
|
||||||
const launcherInternalUrl = baseUrl(env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
|
|
||||||
const internalToken = textValue(
|
|
||||||
providedInternalToken
|
|
||||||
|| env.NODEDC_INTERNAL_ACCESS_TOKEN
|
|
||||||
|| env.NODEDC_PLATFORM_SERVICE_TOKEN,
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
const sessionCookie = textValue(
|
|
||||||
env.NODEDC_DEVICE_MANAGER_SESSION_COOKIE,
|
|
||||||
"nodedc_device_manager_session",
|
|
||||||
);
|
|
||||||
const sessionTtlMs = boundedInteger(
|
|
||||||
env.NODEDC_DEVICE_MANAGER_SESSION_TTL_MS,
|
|
||||||
DEFAULT_SESSION_TTL_MS,
|
|
||||||
60_000,
|
|
||||||
24 * 60 * 60 * 1000,
|
|
||||||
);
|
|
||||||
const validationTtlMs = boundedInteger(
|
|
||||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_TTL_MS,
|
|
||||||
DEFAULT_VALIDATION_TTL_MS,
|
|
||||||
15_000,
|
|
||||||
30_000,
|
|
||||||
);
|
|
||||||
const validationGraceMs = boundedInteger(
|
|
||||||
env.NODEDC_DEVICE_MANAGER_SESSION_VALIDATION_GRACE_MS,
|
|
||||||
DEFAULT_VALIDATION_GRACE_MS,
|
|
||||||
0,
|
|
||||||
60_000,
|
|
||||||
);
|
|
||||||
const secureCookie = booleanValue(
|
|
||||||
env.NODEDC_DEVICE_MANAGER_COOKIE_SECURE,
|
|
||||||
authRequired,
|
|
||||||
);
|
|
||||||
const sessions = new Map();
|
|
||||||
|
|
||||||
function buildCookie(value, maxAgeSeconds) {
|
|
||||||
return [
|
|
||||||
`${sessionCookie}=${encodeURIComponent(value)}`,
|
|
||||||
"Path=/",
|
|
||||||
"HttpOnly",
|
|
||||||
"SameSite=Lax",
|
|
||||||
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
|
||||||
...(secureCookie ? ["Secure"] : []),
|
|
||||||
].join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSession(response, handoff) {
|
|
||||||
pruneSessions();
|
|
||||||
const id = randomBytes(32).toString("base64url");
|
|
||||||
const createdAt = now();
|
|
||||||
sessions.set(id, {
|
|
||||||
id,
|
|
||||||
user: handoff.user,
|
|
||||||
access: handoff.access,
|
|
||||||
launcherSessionId: handoff.launcherSessionId,
|
|
||||||
expiresAt: createdAt + sessionTtlMs,
|
|
||||||
validatedAt: createdAt,
|
|
||||||
validationInFlight: null,
|
|
||||||
});
|
|
||||||
appendCookie(response, buildCookie(id, sessionTtlMs / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentSession(request) {
|
|
||||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
|
||||||
const session = id ? sessions.get(id) : null;
|
|
||||||
if (!session || session.expiresAt <= now()) {
|
|
||||||
if (id) sessions.delete(id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launcherRequest(pathname, payload) {
|
|
||||||
if (!internalToken) throw serviceError("device_manager_auth_not_configured", 503);
|
|
||||||
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${internalToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: AbortSignal.timeout(8_000),
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
return { response, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleHandoff(request, response, url) {
|
|
||||||
const nextPath = safeReturnTo(
|
|
||||||
url.searchParams.get("next_path") || url.searchParams.get("returnTo"),
|
|
||||||
);
|
|
||||||
if (!authRequired) return redirect(response, nextPath);
|
|
||||||
const token = String(url.searchParams.get("token") || "");
|
|
||||||
if (!token) return sendText(response, 400, "Missing Launcher handoff token.");
|
|
||||||
try {
|
|
||||||
const result = await launcherRequest("/api/internal/handoff/consume", {
|
|
||||||
token,
|
|
||||||
serviceSlug,
|
|
||||||
});
|
|
||||||
if (!result.response.ok || result.body?.ok !== true || !result.body?.user) {
|
|
||||||
return sendText(response, 401, "Launcher handoff rejected.");
|
|
||||||
}
|
|
||||||
createSession(response, {
|
|
||||||
user: result.body.user,
|
|
||||||
access: result.body.access,
|
|
||||||
launcherSessionId: result.body.launcherSessionId ?? null,
|
|
||||||
});
|
|
||||||
return redirect(response, nextPath);
|
|
||||||
} catch {
|
|
||||||
return sendText(response, 401, "Launcher handoff failed.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validatedSession(request, response) {
|
|
||||||
if (!authRequired) {
|
|
||||||
return attachSession(request, {
|
|
||||||
user: {
|
|
||||||
id: "local-device-admin",
|
|
||||||
email: "local-device-admin@nodedc.local",
|
|
||||||
name: "Local Device Admin",
|
|
||||||
avatarUrl: null,
|
|
||||||
groups: ["nodedc:superadmin"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const session = currentSession(request);
|
|
||||||
if (!session) {
|
|
||||||
clearCookie(response);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (now() - session.validatedAt <= validationTtlMs) {
|
|
||||||
return attachSession(request, session);
|
|
||||||
}
|
|
||||||
if (!session.validationInFlight) {
|
|
||||||
session.validationInFlight = launcherRequest("/api/internal/session/validate", {
|
|
||||||
serviceSlug,
|
|
||||||
launcherSessionId: session.launcherSessionId,
|
|
||||||
}).finally(() => {
|
|
||||||
session.validationInFlight = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const { response: upstream, body } = await session.validationInFlight;
|
|
||||||
if (upstream.ok && body?.ok === true && body.active === true) {
|
|
||||||
session.user = body.user || session.user;
|
|
||||||
session.access = body.access;
|
|
||||||
session.validatedAt = now();
|
|
||||||
return attachSession(request, session);
|
|
||||||
}
|
|
||||||
if (upstream.ok && body?.ok === true && body.active === false) {
|
|
||||||
sessions.delete(session.id);
|
|
||||||
clearCookie(response);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Read-only grace is resolved below; mutations always fail closed.
|
|
||||||
}
|
|
||||||
const readOnly = request.method === "GET" || request.method === "HEAD";
|
|
||||||
if (readOnly && now() - session.validatedAt <= validationTtlMs + validationGraceMs) {
|
|
||||||
return attachSession(request, session);
|
|
||||||
}
|
|
||||||
request.nodedcDeviceManagerAuthUnavailable = true;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authorize(request, response, url) {
|
|
||||||
if (
|
|
||||||
url.pathname === "/healthz"
|
|
||||||
|| url.pathname === "/auth/nodedc/handoff"
|
|
||||||
|| url.pathname === "/auth/logout"
|
|
||||||
) return false;
|
|
||||||
const session = await validatedSession(request, response);
|
|
||||||
if (!session) {
|
|
||||||
if (request.nodedcDeviceManagerAuthUnavailable) {
|
|
||||||
sendJson(response, 503, { ok: false, error: "device_manager_auth_unavailable" });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const loginUrl = new URL("/auth/login", launcherBaseUrl);
|
|
||||||
const launch = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
|
|
||||||
launch.searchParams.set("returnTo", safeReturnTo(`${url.pathname}${url.search}`));
|
|
||||||
loginUrl.searchParams.set("returnTo", `${launch.pathname}${launch.search}`);
|
|
||||||
if (isHtmlRequest(request, url)) {
|
|
||||||
redirect(response, loginUrl.toString());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
sendJson(response, 401, {
|
|
||||||
ok: false,
|
|
||||||
error: "device_manager_auth_required",
|
|
||||||
loginUrl: loginUrl.toString(),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const access = resolveAccess(session.user, session.access, { allowLegacy: !authRequired });
|
|
||||||
if (!access.allowed) {
|
|
||||||
sendJson(response, 403, {
|
|
||||||
ok: false,
|
|
||||||
error: access.blocked
|
|
||||||
? "device_manager_access_blocked"
|
|
||||||
: "device_manager_access_denied",
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
request.nodedcDeviceManagerAccess = access;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLogout(request, response) {
|
|
||||||
const id = parseCookies(request.headers.cookie)[sessionCookie];
|
|
||||||
if (id) sessions.delete(id);
|
|
||||||
clearCookie(response);
|
|
||||||
redirect(response, "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentContext(request) {
|
|
||||||
const user = request.nodedcDeviceManagerSession?.user;
|
|
||||||
const trustedAccess = request.nodedcDeviceManagerSession?.access;
|
|
||||||
const access = request.nodedcDeviceManagerAccess
|
|
||||||
?? resolveAccess(user, trustedAccess, { allowLegacy: !authRequired });
|
|
||||||
if (!user || !access.allowed) return null;
|
|
||||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
|
||||||
if (!id) return null;
|
|
||||||
const email = String(user.email || "").trim().slice(0, 240);
|
|
||||||
const displayName = String(user.name || user.displayName || email || "NODE.DC")
|
|
||||||
.trim()
|
|
||||||
.slice(0, 240);
|
|
||||||
const avatar = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
|
|
||||||
const userRef = `user:${id}`;
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
displayName,
|
|
||||||
avatarUrl: /^https:\/\//i.test(avatar) || avatar.startsWith("/") ? avatar : null,
|
|
||||||
initials: initials(displayName),
|
|
||||||
},
|
|
||||||
actor: {
|
|
||||||
userRef,
|
|
||||||
hubRole: access.hubRole,
|
|
||||||
groupRefs: access.groups.map((group) => `group:${group}`),
|
|
||||||
ownerScopes: access.ownerScopes,
|
|
||||||
},
|
|
||||||
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCookie(response) {
|
|
||||||
appendCookie(response, buildCookie("", 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneSessions() {
|
|
||||||
const current = now();
|
|
||||||
for (const [id, session] of sessions) {
|
|
||||||
if (session.expiresAt <= current) sessions.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
authRequired,
|
|
||||||
internalAccessConfigured: Boolean(internalToken),
|
|
||||||
serviceSlug,
|
|
||||||
authorize,
|
|
||||||
currentContext,
|
|
||||||
handleHandoff,
|
|
||||||
handleLogout,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAccess(user, trustedAccess, { allowLegacy = false } = {}) {
|
|
||||||
if (!user || typeof user !== "object") {
|
|
||||||
return deniedAccess();
|
|
||||||
}
|
|
||||||
const groups = normalizedGroups(user);
|
|
||||||
if (groups.includes("nodedc:device-core:blocked")) {
|
|
||||||
return { ...deniedAccess(groups), blocked: true };
|
|
||||||
}
|
|
||||||
const id = cleanOpaque(user.id || user.subject || user.sub);
|
|
||||||
if (!id) return deniedAccess(groups);
|
|
||||||
const claims = normalizeTrustedAccess(trustedAccess, id);
|
|
||||||
if (claims) return { ...claims, blocked: false, groups };
|
|
||||||
if (!allowLegacy) return deniedAccess(groups);
|
|
||||||
const hubRole = id === "user_root" || groups.includes("nodedc:superadmin")
|
|
||||||
? "owner"
|
|
||||||
: groups.includes("nodedc:device-core:admin") || groups.includes("nodedc:launcher:admin")
|
|
||||||
? "admin"
|
|
||||||
: groups.includes("nodedc:device-core:viewer")
|
|
||||||
? "viewer"
|
|
||||||
: "member";
|
|
||||||
const ownerScopes = ["admin", "owner"].includes(hubRole)
|
|
||||||
? [{
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: `user:${id}`,
|
|
||||||
displayName: String(user.name || user.displayName || user.email || id).trim().slice(0, 240),
|
|
||||||
}]
|
|
||||||
: [];
|
|
||||||
return { allowed: true, blocked: false, hubRole, groups, ownerScopes };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTrustedAccess(input, userId) {
|
|
||||||
if (!input || typeof input !== "object" || input.allowed !== true) return null;
|
|
||||||
const hubRole = ["viewer", "member", "admin", "owner"].includes(input.hubRole)
|
|
||||||
? input.hubRole
|
|
||||||
: null;
|
|
||||||
if (!hubRole || !Array.isArray(input.ownerScopes)) return null;
|
|
||||||
const ownerScopes = [];
|
|
||||||
for (const item of input.ownerScopes) {
|
|
||||||
if (!item || typeof item !== "object") return null;
|
|
||||||
const scopeKind = item.scopeKind === "company" || item.scopeKind === "personal"
|
|
||||||
? item.scopeKind
|
|
||||||
: null;
|
|
||||||
const ownerRef = cleanOpaque(item.ownerRef);
|
|
||||||
const validOwner = scopeKind === "personal"
|
|
||||||
? ownerRef === `user:${userId}`
|
|
||||||
: ownerRef?.startsWith("client:") && ownerRef.length > "client:".length;
|
|
||||||
if (!scopeKind || !validOwner) return null;
|
|
||||||
ownerScopes.push({
|
|
||||||
scopeKind,
|
|
||||||
ownerRef,
|
|
||||||
displayName: String(item.displayName || ownerRef).trim().slice(0, 240),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
hubRole,
|
|
||||||
ownerScopes: [...new Map(ownerScopes.map((scope) => [
|
|
||||||
`${scope.scopeKind}\0${scope.ownerRef}`,
|
|
||||||
scope,
|
|
||||||
])).values()],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function deniedAccess(groups = []) {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
blocked: false,
|
|
||||||
hubRole: "viewer",
|
|
||||||
groups,
|
|
||||||
ownerScopes: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedGroups(user) {
|
|
||||||
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
|
|
||||||
const groups = [];
|
|
||||||
for (const value of values) {
|
|
||||||
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
||||||
for (const item of items) {
|
|
||||||
const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug;
|
|
||||||
const normalized = String(raw || "").trim().toLowerCase();
|
|
||||||
if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...new Set(groups)].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachSession(request, session) {
|
|
||||||
request.nodedcDeviceManagerSession = session;
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanOpaque(value) {
|
|
||||||
const normalized = String(value || "").trim();
|
|
||||||
return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized)
|
|
||||||
? normalized
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCookies(header = "") {
|
|
||||||
const values = {};
|
|
||||||
for (const part of String(header).split(";")) {
|
|
||||||
const index = part.indexOf("=");
|
|
||||||
if (index < 1) continue;
|
|
||||||
const key = part.slice(0, index).trim();
|
|
||||||
try {
|
|
||||||
values[key] = decodeURIComponent(part.slice(index + 1).trim());
|
|
||||||
} catch {
|
|
||||||
values[key] = part.slice(index + 1).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCookie(response, value) {
|
|
||||||
const current = response.getHeader("Set-Cookie");
|
|
||||||
response.setHeader("Set-Cookie", current ? [current, value].flat() : value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeReturnTo(value) {
|
|
||||||
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
|
|
||||||
? value
|
|
||||||
: "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHtmlRequest(request, url) {
|
|
||||||
return request.method === "GET"
|
|
||||||
&& !url.pathname.startsWith("/api/")
|
|
||||||
&& (url.pathname === "/" || String(request.headers.accept || "").includes("text/html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function redirect(response, location) {
|
|
||||||
response.statusCode = 302;
|
|
||||||
response.setHeader("Location", location);
|
|
||||||
response.setHeader("Cache-Control", "no-store");
|
|
||||||
response.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(response, status, body) {
|
|
||||||
response.statusCode = status;
|
|
||||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
||||||
response.setHeader("Cache-Control", "no-store");
|
|
||||||
response.end(JSON.stringify(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendText(response, status, body) {
|
|
||||||
response.statusCode = status;
|
|
||||||
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
||||||
response.setHeader("Cache-Control", "no-store");
|
|
||||||
response.end(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(value) {
|
|
||||||
return value.split(/\s+/).filter(Boolean).slice(0, 2)
|
|
||||||
.map((part) => part[0]).join("").toUpperCase() || "DC";
|
|
||||||
}
|
|
||||||
|
|
||||||
function booleanValue(value, fallback) {
|
|
||||||
if (value == null || value === "") return fallback;
|
|
||||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundedInteger(value, fallback, min, max) {
|
|
||||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
||||||
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function textValue(value, fallback) {
|
|
||||||
return String(value || fallback).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function baseUrl(value, fallback) {
|
|
||||||
return textValue(value, fallback).replace(/\/$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceError(code, statusCode) {
|
|
||||||
const error = new Error(code);
|
|
||||||
error.statusCode = statusCode;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
|
||||||
|
|
||||||
const internalToken = "launcher-internal-token-must-stay-server-side";
|
|
||||||
const launcherSessionId = "launcher-session-id-must-stay-server-side";
|
|
||||||
|
|
||||||
test("Launcher handoff becomes an opaque Device Manager session and trusted actor", async () => {
|
|
||||||
const calls = [];
|
|
||||||
const auth = createDeviceManagerAuth({
|
|
||||||
env: productionEnv(),
|
|
||||||
fetchImpl: async (url, init) => {
|
|
||||||
calls.push({ url: String(url), init });
|
|
||||||
return jsonResponse(200, {
|
|
||||||
ok: true,
|
|
||||||
launcherSessionId,
|
|
||||||
access: {
|
|
||||||
allowed: true,
|
|
||||||
hubRole: "owner",
|
|
||||||
ownerScopes: [
|
|
||||||
{
|
|
||||||
scopeKind: "company",
|
|
||||||
ownerRef: "client:client_dctouch",
|
|
||||||
displayName: "DC Touch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: "user:user_root",
|
|
||||||
displayName: "DC SUDO",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
id: "user_root",
|
|
||||||
email: "root@example.test",
|
|
||||||
name: "DC SUDO",
|
|
||||||
groups: ["nodedc:superadmin", "nodedc:device-core:admin"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = mockResponse();
|
|
||||||
await auth.handleHandoff(
|
|
||||||
{ method: "GET", headers: {} },
|
|
||||||
response,
|
|
||||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret&next_path=%2F"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(response.statusCode, 302);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
assert.equal(calls[0].init.headers.Authorization, `Bearer ${internalToken}`);
|
|
||||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
|
||||||
token: "handoff-secret",
|
|
||||||
serviceSlug: "device-core",
|
|
||||||
});
|
|
||||||
const cookie = String(response.getHeader("set-cookie")).split(";", 1)[0];
|
|
||||||
assert.match(cookie, /^nodedc_device_manager_session=[A-Za-z0-9_-]{40,}$/);
|
|
||||||
assert.equal(cookie.includes("user_root"), false);
|
|
||||||
assert.equal(cookie.includes(launcherSessionId), false);
|
|
||||||
|
|
||||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
|
||||||
const authorized = await auth.authorize(
|
|
||||||
request,
|
|
||||||
mockResponse(),
|
|
||||||
new URL("https://device.example.test/api/device-manager/session"),
|
|
||||||
);
|
|
||||||
assert.equal(authorized, false);
|
|
||||||
const context = auth.currentContext(request);
|
|
||||||
assert.equal(context.actor.userRef, "user:user_root");
|
|
||||||
assert.equal(context.actor.hubRole, "owner");
|
|
||||||
assert.deepEqual(context.actor.ownerScopes, [
|
|
||||||
{
|
|
||||||
scopeKind: "company",
|
|
||||||
ownerRef: "client:client_dctouch",
|
|
||||||
displayName: "DC Touch",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: "user:user_root",
|
|
||||||
displayName: "DC SUDO",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
assert.deepEqual(context.actor.groupRefs, [
|
|
||||||
"group:nodedc:device-core:admin",
|
|
||||||
"group:nodedc:superadmin",
|
|
||||||
]);
|
|
||||||
assert.equal(JSON.stringify(context).includes(launcherSessionId), false);
|
|
||||||
assert.equal(JSON.stringify(context).includes(internalToken), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid identity and explicit Device Core block never produce an actor", async () => {
|
|
||||||
for (const user of [
|
|
||||||
{ id: "?", groups: ["nodedc:device-core:admin"] },
|
|
||||||
{ id: "valid-user", groups: ["nodedc:superadmin", "nodedc:device-core:blocked"] },
|
|
||||||
]) {
|
|
||||||
const auth = createDeviceManagerAuth({
|
|
||||||
env: productionEnv(),
|
|
||||||
fetchImpl: async () => jsonResponse(200, {
|
|
||||||
ok: true,
|
|
||||||
launcherSessionId,
|
|
||||||
access: {
|
|
||||||
allowed: true,
|
|
||||||
hubRole: "admin",
|
|
||||||
ownerScopes: [],
|
|
||||||
},
|
|
||||||
user,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const handoff = mockResponse();
|
|
||||||
await auth.handleHandoff(
|
|
||||||
{ method: "GET", headers: {} },
|
|
||||||
handoff,
|
|
||||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
|
||||||
);
|
|
||||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
|
||||||
const request = { method: "GET", headers: { cookie, accept: "application/json" } };
|
|
||||||
const response = mockResponse();
|
|
||||||
assert.equal(await auth.authorize(
|
|
||||||
request,
|
|
||||||
response,
|
|
||||||
new URL("https://device.example.test/api/device-manager/session"),
|
|
||||||
), true);
|
|
||||||
assert.equal(response.statusCode, 403);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("production auth fails closed when Launcher omits trusted Device Core access", async () => {
|
|
||||||
const auth = createDeviceManagerAuth({
|
|
||||||
env: productionEnv(),
|
|
||||||
fetchImpl: async () => jsonResponse(200, {
|
|
||||||
ok: true,
|
|
||||||
launcherSessionId,
|
|
||||||
user: {
|
|
||||||
id: "user_root",
|
|
||||||
email: "root@example.test",
|
|
||||||
name: "DC SUDO",
|
|
||||||
groups: ["nodedc:superadmin"],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const handoff = mockResponse();
|
|
||||||
await auth.handleHandoff(
|
|
||||||
{ method: "GET", headers: {} },
|
|
||||||
handoff,
|
|
||||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
|
||||||
);
|
|
||||||
const cookie = String(handoff.getHeader("set-cookie")).split(";", 1)[0];
|
|
||||||
const response = mockResponse();
|
|
||||||
assert.equal(await auth.authorize(
|
|
||||||
{ method: "GET", headers: { cookie, accept: "application/json" } },
|
|
||||||
response,
|
|
||||||
new URL("https://device.example.test/api/device-manager/session"),
|
|
||||||
), true);
|
|
||||||
assert.equal(response.statusCode, 403);
|
|
||||||
assert.equal(JSON.parse(response.body).error, "device_manager_access_denied");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an injected file-backed token takes precedence over broad platform env tokens", async () => {
|
|
||||||
const calls = [];
|
|
||||||
const auth = createDeviceManagerAuth({
|
|
||||||
env: { ...productionEnv(), NODEDC_INTERNAL_ACCESS_TOKEN: "broad-platform-token" },
|
|
||||||
internalToken: "scoped-file-token",
|
|
||||||
fetchImpl: async (url, init) => {
|
|
||||||
calls.push({ url, init });
|
|
||||||
return jsonResponse(200, {
|
|
||||||
ok: true,
|
|
||||||
launcherSessionId,
|
|
||||||
access: { allowed: true, hubRole: "member", ownerScopes: [] },
|
|
||||||
user: {
|
|
||||||
id: "device-member",
|
|
||||||
email: "member@example.test",
|
|
||||||
name: "Device Member",
|
|
||||||
groups: ["nodedc:device-core:access"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await auth.handleHandoff(
|
|
||||||
{ method: "GET", headers: {} },
|
|
||||||
mockResponse(),
|
|
||||||
new URL("https://device.example.test/auth/nodedc/handoff?token=handoff-secret"),
|
|
||||||
);
|
|
||||||
assert.equal(calls[0].init.headers.Authorization, "Bearer scoped-file-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
function productionEnv() {
|
|
||||||
return {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
|
||||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "false",
|
|
||||||
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
|
|
||||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
|
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN: internalToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockResponse() {
|
|
||||||
const headers = new Map();
|
|
||||||
return {
|
|
||||||
statusCode: 200,
|
|
||||||
body: "",
|
|
||||||
setHeader(name, value) { headers.set(String(name).toLowerCase(), value); },
|
|
||||||
getHeader(name) { return headers.get(String(name).toLowerCase()); },
|
|
||||||
end(body = "") { this.body = String(body); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonResponse(status, body) {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { createReadStream } from "node:fs";
|
|
||||||
import { readFile, stat } from "node:fs/promises";
|
|
||||||
import { createServer } from "node:http";
|
|
||||||
import { dirname, extname, resolve, sep } from "node:path";
|
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
||||||
|
|
||||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
|
||||||
import {
|
|
||||||
createDeviceCoreClient,
|
|
||||||
createLocalPreviewDeviceCore,
|
|
||||||
} from "./device-core-client.mjs";
|
|
||||||
|
|
||||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
const mutationRoutes = new Map([
|
|
||||||
["/api/device-manager/owner-scopes:ensure", "owner-scopes:ensure"],
|
|
||||||
["/api/device-manager/projects:ensure", "projects:ensure"],
|
|
||||||
["/api/device-manager/collections:ensure", "collections:ensure"],
|
|
||||||
["/api/device-manager/project-grants:upsert", "project-grants:upsert"],
|
|
||||||
["/api/device-manager/adapter-packages:ensure", "adapter-packages:ensure"],
|
|
||||||
["/api/device-manager/adapter-versions:register", "adapter-versions:register"],
|
|
||||||
["/api/device-manager/model-profiles:register", "model-profiles:register"],
|
|
||||||
["/api/device-manager/edges:ensure", "edges:ensure"],
|
|
||||||
["/api/device-manager/routes:ensure", "routes:ensure"],
|
|
||||||
["/api/device-manager/enrollment-intents:ensure", "enrollment-intents:ensure"],
|
|
||||||
["/api/device-manager/devices:claim", "devices:claim"],
|
|
||||||
["/api/device-manager/device-bindings:ensure", "device-bindings:ensure"],
|
|
||||||
["/api/device-manager/device-bindings:revoke", "device-bindings:revoke"],
|
|
||||||
[
|
|
||||||
"/api/device-manager/device-configuration-revisions:create",
|
|
||||||
"device-configuration-revisions:create",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"/api/device-manager/device-configurations:set-desired",
|
|
||||||
"device-configurations:set-desired",
|
|
||||||
],
|
|
||||||
["/api/device-manager/commands:service-ping", "commands:service-ping"],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function createDeviceManagerServer({
|
|
||||||
auth,
|
|
||||||
coreClient,
|
|
||||||
distRoot = resolve(appRoot, "dist"),
|
|
||||||
} = {}) {
|
|
||||||
if (!auth || typeof auth.authorize !== "function") {
|
|
||||||
throw new TypeError("device_manager_auth_required");
|
|
||||||
}
|
|
||||||
if (!coreClient || typeof coreClient.listProjects !== "function") {
|
|
||||||
throw new TypeError("device_manager_core_client_required");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createServer(async (request, response) => {
|
|
||||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
||||||
response.setHeader("Referrer-Policy", "same-origin");
|
|
||||||
response.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
||||||
try {
|
|
||||||
const url = new URL(
|
|
||||||
request.url || "/",
|
|
||||||
`http://${request.headers.host || "127.0.0.1"}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname === "/healthz") {
|
|
||||||
return sendJson(response, 200, {
|
|
||||||
ok: true,
|
|
||||||
service: "nodedc-device-manager",
|
|
||||||
authRequired: auth.authRequired,
|
|
||||||
deviceCoreConfigured: coreClient.configured === true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (request.method === "GET" && url.pathname === "/auth/nodedc/handoff") {
|
|
||||||
return auth.handleHandoff(request, response, url);
|
|
||||||
}
|
|
||||||
if (request.method === "GET" && url.pathname === "/auth/logout") {
|
|
||||||
return auth.handleLogout(request, response);
|
|
||||||
}
|
|
||||||
if (await auth.authorize(request, response, url)) return;
|
|
||||||
const context = auth.currentContext(request);
|
|
||||||
if (!context) return sendJson(response, 401, {
|
|
||||||
ok: false,
|
|
||||||
error: "device_manager_auth_required",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname === "/api/device-manager/session") {
|
|
||||||
return sendJson(response, 200, { ok: true, session: context });
|
|
||||||
}
|
|
||||||
if (request.method === "GET" && url.pathname === "/api/device-manager/projects") {
|
|
||||||
const projects = await coreClient.listProjects(context.actor);
|
|
||||||
return sendJson(response, 200, { ok: true, projects });
|
|
||||||
}
|
|
||||||
const projectRef = workspaceProjectRef(url.pathname);
|
|
||||||
if (request.method === "GET" && projectRef) {
|
|
||||||
const workspace = await coreClient.getWorkspace(context.actor, projectRef);
|
|
||||||
return sendJson(response, 200, { ok: true, workspace });
|
|
||||||
}
|
|
||||||
const command = mutationRoutes.get(url.pathname);
|
|
||||||
if (request.method === "POST" && command) {
|
|
||||||
const idempotencyKey = singleHeader(request.headers["idempotency-key"]);
|
|
||||||
const input = await readJsonBody(request, 64 * 1024);
|
|
||||||
const execution = await coreClient.execute(
|
|
||||||
command,
|
|
||||||
context.actor,
|
|
||||||
input,
|
|
||||||
idempotencyKey,
|
|
||||||
);
|
|
||||||
response.setHeader("Idempotency-Key", idempotencyKey);
|
|
||||||
response.setHeader(
|
|
||||||
"Idempotency-Replayed",
|
|
||||||
execution.replayed ? "true" : "false",
|
|
||||||
);
|
|
||||||
return sendJson(response, 200, { ok: true, ...execution });
|
|
||||||
}
|
|
||||||
if (url.pathname.startsWith("/api/")) {
|
|
||||||
return sendJson(response, 404, { ok: false, error: "device_manager_route_not_found" });
|
|
||||||
}
|
|
||||||
return serveStatic(request, response, url, distRoot);
|
|
||||||
} catch (error) {
|
|
||||||
if (response.headersSent) {
|
|
||||||
response.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const statusCode = normalizeStatus(error?.statusCode);
|
|
||||||
return sendJson(response, statusCode, {
|
|
||||||
ok: false,
|
|
||||||
error: safeError(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createConfiguredDeviceManagerServer({ env = process.env } = {}) {
|
|
||||||
const localPreview = booleanValue(env.NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW, false);
|
|
||||||
const launcherTokenFile = String(env.NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE || "").trim();
|
|
||||||
const launcherInternalToken = launcherTokenFile
|
|
||||||
? (await readFile(launcherTokenFile, "utf8")).trim()
|
|
||||||
: undefined;
|
|
||||||
const auth = createDeviceManagerAuth({ env, internalToken: launcherInternalToken });
|
|
||||||
if (auth.authRequired && !auth.internalAccessConfigured) {
|
|
||||||
throw new Error("device_manager_auth_token_file_required");
|
|
||||||
}
|
|
||||||
let coreClient;
|
|
||||||
if (localPreview) {
|
|
||||||
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
|
|
||||||
throw new Error("device_manager_local_preview_forbidden");
|
|
||||||
}
|
|
||||||
coreClient = createLocalPreviewDeviceCore({
|
|
||||||
fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim();
|
|
||||||
if (!tokenFile) throw new Error("device_core_token_file_required");
|
|
||||||
const token = (await readFile(tokenFile, "utf8")).trim();
|
|
||||||
coreClient = createDeviceCoreClient({
|
|
||||||
baseUrl: env.NODEDC_DEVICE_CORE_INTERNAL_URL,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createDeviceManagerServer({ auth, coreClient });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function serveStatic(request, response, url, root) {
|
|
||||||
const requestedPath = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
||||||
const candidate = resolve(root, `.${decodeURIComponent(requestedPath)}`);
|
|
||||||
const normalizedRoot = resolve(root);
|
|
||||||
if (candidate !== normalizedRoot && !candidate.startsWith(`${normalizedRoot}${sep}`)) {
|
|
||||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
|
||||||
}
|
|
||||||
let filePath = candidate;
|
|
||||||
let info = await stat(filePath).catch(() => null);
|
|
||||||
if ((!info || !info.isFile()) && !extname(requestedPath)) {
|
|
||||||
filePath = resolve(root, "index.html");
|
|
||||||
info = await stat(filePath).catch(() => null);
|
|
||||||
}
|
|
||||||
if (!info?.isFile()) {
|
|
||||||
return sendJson(response, 404, { ok: false, error: "device_manager_asset_not_found" });
|
|
||||||
}
|
|
||||||
response.statusCode = 200;
|
|
||||||
response.setHeader("Content-Type", contentType(filePath));
|
|
||||||
response.setHeader(
|
|
||||||
"Cache-Control",
|
|
||||||
filePath.endsWith("index.html") ? "no-store" : "private, max-age=300",
|
|
||||||
);
|
|
||||||
response.setHeader("Content-Length", info.size);
|
|
||||||
if (request.method === "HEAD") return response.end();
|
|
||||||
createReadStream(filePath).pipe(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceProjectRef(pathname) {
|
|
||||||
const match = pathname.match(/^\/api\/device-manager\/projects\/([^/]+)\/workspace$/);
|
|
||||||
if (!match) return null;
|
|
||||||
const projectRef = decodeURIComponent(match[1]);
|
|
||||||
return /^project:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(projectRef)
|
|
||||||
? projectRef.toLowerCase()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readJsonBody(request, maxBytes) {
|
|
||||||
let size = 0;
|
|
||||||
const chunks = [];
|
|
||||||
for await (const chunk of request) {
|
|
||||||
size += chunk.length;
|
|
||||||
if (size > maxBytes) throw serviceError("device_manager_request_too_large", 413);
|
|
||||||
chunks.push(chunk);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
||||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error();
|
|
||||||
return body;
|
|
||||||
} catch {
|
|
||||||
throw serviceError("device_manager_json_invalid", 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function singleHeader(value) {
|
|
||||||
if (Array.isArray(value) || typeof value !== "string") {
|
|
||||||
throw serviceError("device_idempotency_key_invalid", 400);
|
|
||||||
}
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (!/^[\x21-\x7e]{8,256}$/.test(normalized)) {
|
|
||||||
throw serviceError("device_idempotency_key_invalid", 400);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(response, statusCode, body) {
|
|
||||||
response.statusCode = statusCode;
|
|
||||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
||||||
response.setHeader("Cache-Control", "no-store");
|
|
||||||
response.end(JSON.stringify(body));
|
|
||||||
}
|
|
||||||
|
|
||||||
function contentType(pathname) {
|
|
||||||
return ({
|
|
||||||
".html": "text/html; charset=utf-8",
|
|
||||||
".js": "text/javascript; charset=utf-8",
|
|
||||||
".css": "text/css; charset=utf-8",
|
|
||||||
".svg": "image/svg+xml",
|
|
||||||
".png": "image/png",
|
|
||||||
".ico": "image/x-icon",
|
|
||||||
})[extname(pathname).toLowerCase()] || "application/octet-stream";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStatus(value) {
|
|
||||||
const status = Number(value || 500);
|
|
||||||
return Number.isInteger(status) && status >= 400 && status < 600 ? status : 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeError(error) {
|
|
||||||
const value = String(error?.message || "");
|
|
||||||
return /^(?:device|nodedc)_[a-z0-9._:-]{2,160}$/.test(value)
|
|
||||||
? value
|
|
||||||
: "device_manager_internal_error";
|
|
||||||
}
|
|
||||||
|
|
||||||
function booleanValue(value, fallback) {
|
|
||||||
if (value == null || value === "") return fallback;
|
|
||||||
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
function serviceError(code, statusCode) {
|
|
||||||
const error = new Error(code);
|
|
||||||
error.statusCode = statusCode;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
||||||
const server = await createConfiguredDeviceManagerServer();
|
|
||||||
const port = Number.parseInt(process.env.PORT || "3335", 10);
|
|
||||||
const host = String(process.env.HOST || "127.0.0.1");
|
|
||||||
server.listen(port, host, () => {
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
event: "device_manager_started",
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
|
|
||||||
import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
|
|
||||||
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
|
|
||||||
import {
|
|
||||||
createConfiguredDeviceManagerServer,
|
|
||||||
createDeviceManagerServer,
|
|
||||||
} from "./device-manager-server.mjs";
|
|
||||||
|
|
||||||
test("production configuration starts with runner-owned file tokens", async (t) => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "nodedc-device-manager-production-"));
|
|
||||||
const launcherTokenFile = join(root, "launcher-token");
|
|
||||||
const coreTokenFile = join(root, "core-token");
|
|
||||||
await writeFile(launcherTokenFile, `${"a".repeat(48)}\n`, { mode: 0o640 });
|
|
||||||
await writeFile(coreTokenFile, `${"b".repeat(48)}\n`, { mode: 0o640 });
|
|
||||||
t.after(() => rm(root, { recursive: true, force: true }));
|
|
||||||
|
|
||||||
const server = await createConfiguredDeviceManagerServer({
|
|
||||||
env: {
|
|
||||||
NODE_ENV: "production",
|
|
||||||
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
|
|
||||||
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "true",
|
|
||||||
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW: "false",
|
|
||||||
NODEDC_DEVICE_MANAGER_SERVICE_SLUG: "device-core",
|
|
||||||
NODEDC_LAUNCHER_BASE_URL: "https://hub.nodedc.ru",
|
|
||||||
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher:5173",
|
|
||||||
NODEDC_LAUNCHER_INTERNAL_TOKEN_FILE: launcherTokenFile,
|
|
||||||
NODEDC_DEVICE_CORE_INTERNAL_URL: "http://device-control-core:18120",
|
|
||||||
NODEDC_DEVICE_CORE_TOKEN_FILE: coreTokenFile,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(0, "127.0.0.1", resolve);
|
|
||||||
});
|
|
||||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
||||||
|
|
||||||
const address = server.address();
|
|
||||||
const response = await fetch(`http://127.0.0.1:${address.port}/healthz`);
|
|
||||||
assert.equal(response.status, 200);
|
|
||||||
assert.deepEqual(await response.json(), {
|
|
||||||
ok: true,
|
|
||||||
service: "nodedc-device-manager",
|
|
||||||
authRequired: true,
|
|
||||||
deviceCoreConfigured: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootResponse = await fetch(`http://127.0.0.1:${address.port}/`, {
|
|
||||||
redirect: "manual",
|
|
||||||
headers: { accept: "text/html" },
|
|
||||||
});
|
|
||||||
assert.equal(rootResponse.status, 302);
|
|
||||||
assert.equal(
|
|
||||||
rootResponse.headers.get("location"),
|
|
||||||
"https://hub.nodedc.ru/auth/login?returnTo=%2Fapi%2Fservices%2Fdevice-core%2Flaunch%3FreturnTo%3D%252F",
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthAfterRedirect = await fetch(`http://127.0.0.1:${address.port}/healthz`);
|
|
||||||
assert.equal(healthAfterRedirect.status, 200);
|
|
||||||
assert.equal((await healthAfterRedirect.json()).ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Device Manager BFF exposes an empty, mutation-driven project workspace", async (t) => {
|
|
||||||
const auth = createDeviceManagerAuth({
|
|
||||||
env: { NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "false" },
|
|
||||||
});
|
|
||||||
const coreClient = createLocalPreviewDeviceCore();
|
|
||||||
const server = createDeviceManagerServer({ auth, coreClient });
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
server.once("error", reject);
|
|
||||||
server.listen(0, "127.0.0.1", resolve);
|
|
||||||
});
|
|
||||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
|
||||||
const address = server.address();
|
|
||||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
|
||||||
|
|
||||||
const session = await getJson(`${baseUrl}/api/device-manager/session`);
|
|
||||||
assert.equal(session.session.actor.hubRole, "owner");
|
|
||||||
assert.equal(session.session.actor.userRef, "user:local-device-admin");
|
|
||||||
assert.deepEqual((await getJson(`${baseUrl}/api/device-manager/projects`)).projects, []);
|
|
||||||
|
|
||||||
await postJson(`${baseUrl}/api/device-manager/owner-scopes:ensure`, {
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: "user:local-device-admin",
|
|
||||||
displayName: "Local Device Admin",
|
|
||||||
}, {
|
|
||||||
"X-NODEDC-Hub-Role": "viewer",
|
|
||||||
"X-NODEDC-User-Ref": "user:spoofed-browser",
|
|
||||||
});
|
|
||||||
const created = await postJson(`${baseUrl}/api/device-manager/projects:ensure`, {
|
|
||||||
scopeKind: "personal",
|
|
||||||
ownerRef: "user:local-device-admin",
|
|
||||||
projectKey: "device-sandbox",
|
|
||||||
name: "Device sandbox",
|
|
||||||
description: "Created only through the canonical command path",
|
|
||||||
});
|
|
||||||
const projectRef = created.result.project.projectRef;
|
|
||||||
|
|
||||||
const projects = (await getJson(`${baseUrl}/api/device-manager/projects`)).projects;
|
|
||||||
assert.equal(projects.length, 1);
|
|
||||||
assert.equal(projects[0].projectKey, "device-sandbox");
|
|
||||||
assert.equal(projects[0].ownerScope.ownerRef, "user:local-device-admin");
|
|
||||||
|
|
||||||
await postJson(`${baseUrl}/api/device-manager/collections:ensure`, {
|
|
||||||
projectRef,
|
|
||||||
collectionKey: "pilot-devices",
|
|
||||||
name: "Pilot devices",
|
|
||||||
description: null,
|
|
||||||
});
|
|
||||||
await postJson(`${baseUrl}/api/device-manager/adapter-packages:ensure`, {
|
|
||||||
packageKey: "generic-sensor",
|
|
||||||
displayName: "Generic sensor",
|
|
||||||
publisherRef: "publisher:nodedc",
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
await postJson(`${baseUrl}/api/device-manager/edges:ensure`, {
|
|
||||||
edgeKey: "preview-edge",
|
|
||||||
displayName: "Preview Edge",
|
|
||||||
deploymentRef: "deployment:preview-edge",
|
|
||||||
lifecycleState: "provisioning",
|
|
||||||
});
|
|
||||||
await postJson(`${baseUrl}/api/device-manager/project-grants:upsert`, {
|
|
||||||
projectRef,
|
|
||||||
principalKind: "group",
|
|
||||||
principalRef: "group:preview-viewers",
|
|
||||||
projectRole: "viewer",
|
|
||||||
capabilityAllow: [],
|
|
||||||
capabilityDeny: [],
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
const workspace = await getJson(
|
|
||||||
`${baseUrl}/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
|
||||||
);
|
|
||||||
assert.equal(workspace.workspace.project.projectRef, projectRef);
|
|
||||||
assert.equal(workspace.workspace.collections[0].collectionKey, "pilot-devices");
|
|
||||||
assert.equal(workspace.workspace.adapterPackages[0].packageKey, "generic-sensor");
|
|
||||||
assert.equal(workspace.workspace.edges[0].edgeKey, "preview-edge");
|
|
||||||
assert.equal(workspace.workspace.grants.length, 2);
|
|
||||||
assert.equal(workspace.workspace.policies.commandTransport, "disabled");
|
|
||||||
assert.deepEqual(workspace.workspace.devices, []);
|
|
||||||
|
|
||||||
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
assert.equal(missingKey.status, 400);
|
|
||||||
assert.equal((await missingKey.json()).error, "device_idempotency_key_invalid");
|
|
||||||
});
|
|
||||||
|
|
||||||
async function getJson(url) {
|
|
||||||
const response = await fetch(url, { headers: { accept: "application/json" } });
|
|
||||||
const body = await response.json();
|
|
||||||
assert.equal(response.status, 200, JSON.stringify(body));
|
|
||||||
assert.equal(body.ok, true);
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postJson(url, body, headers = {}) {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
accept: "application/json",
|
|
||||||
"content-type": "application/json",
|
|
||||||
"idempotency-key": `device-manager-test-${crypto.randomUUID()}`,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const payload = await response.json();
|
|
||||||
assert.equal(response.status, 200, JSON.stringify(payload));
|
|
||||||
assert.equal(payload.ok, true);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
@@ -1,955 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
GlassSurface,
|
|
||||||
Icon,
|
|
||||||
Select,
|
|
||||||
SettingsCard,
|
|
||||||
StatusBadge,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
Window,
|
|
||||||
WindowFooterActions,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
createConfigurationRevision,
|
|
||||||
ensureAdapterPackage,
|
|
||||||
ensureDeviceBinding,
|
|
||||||
ensureEdge,
|
|
||||||
ensureRoute,
|
|
||||||
registerAdapterVersion,
|
|
||||||
registerModelProfile,
|
|
||||||
revokeDeviceBinding,
|
|
||||||
sendServicePing,
|
|
||||||
setDesiredConfiguration,
|
|
||||||
upsertProjectGrant,
|
|
||||||
} from "./api";
|
|
||||||
import type {
|
|
||||||
AdapterPackageView,
|
|
||||||
AdapterVersionView,
|
|
||||||
BindingView,
|
|
||||||
DeviceManagerSession,
|
|
||||||
EdgeView,
|
|
||||||
ModelProfileView,
|
|
||||||
ProjectWorkspace,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export type ControlViewId =
|
|
||||||
| "catalog"
|
|
||||||
| "infrastructure"
|
|
||||||
| "sessions"
|
|
||||||
| "bindings"
|
|
||||||
| "commands"
|
|
||||||
| "audit"
|
|
||||||
| "access"
|
|
||||||
| "settings";
|
|
||||||
|
|
||||||
type DialogId =
|
|
||||||
| "adapter-package"
|
|
||||||
| "adapter-version"
|
|
||||||
| "model-profile"
|
|
||||||
| "edge"
|
|
||||||
| "route"
|
|
||||||
| "binding"
|
|
||||||
| "grant"
|
|
||||||
| "configuration"
|
|
||||||
| null;
|
|
||||||
|
|
||||||
export function DeviceControlView({
|
|
||||||
view,
|
|
||||||
workspace,
|
|
||||||
session,
|
|
||||||
onRefresh,
|
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
view: ControlViewId;
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
session: DeviceManagerSession;
|
|
||||||
onRefresh: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const [dialog, setDialog] = useState<DialogId>(null);
|
|
||||||
const capabilities = new Set(workspace.project.access.capabilities);
|
|
||||||
const platformOwner = session.actor.hubRole === "owner";
|
|
||||||
const close = () => setDialog(null);
|
|
||||||
const completed = async () => {
|
|
||||||
close();
|
|
||||||
await onRefresh();
|
|
||||||
};
|
|
||||||
const mutateAndRefresh = async (mutation: () => Promise<unknown>) => {
|
|
||||||
try {
|
|
||||||
await mutation();
|
|
||||||
await onRefresh();
|
|
||||||
} catch (reason) {
|
|
||||||
onError(reason);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{view === "catalog" ? (
|
|
||||||
<CatalogView
|
|
||||||
workspace={workspace}
|
|
||||||
canManage={platformOwner}
|
|
||||||
onCreatePackage={() => setDialog("adapter-package")}
|
|
||||||
onCreateVersion={() => setDialog("adapter-version")}
|
|
||||||
onCreateProfile={() => setDialog("model-profile")}
|
|
||||||
onActivateVersion={(version) => mutateAndRefresh(() => registerAdapterVersion({
|
|
||||||
adapterPackageRef: version.adapterPackageRef,
|
|
||||||
version: version.version,
|
|
||||||
runtimePackageRef: version.runtimePackageRef,
|
|
||||||
contentDigest: version.contentDigest,
|
|
||||||
contractVersion: version.contractVersion,
|
|
||||||
capabilities: version.capabilities,
|
|
||||||
lifecycleState: "active",
|
|
||||||
}))}
|
|
||||||
onActivateProfile={(profile) => mutateAndRefresh(() => registerModelProfile({
|
|
||||||
adapterVersionRef: profile.adapterVersionRef || "",
|
|
||||||
profileRef: profile.modelProfileRef,
|
|
||||||
schemaVersion: profile.schemaVersion,
|
|
||||||
vendor: profile.vendor,
|
|
||||||
model: profile.model,
|
|
||||||
deviceType: profile.deviceType,
|
|
||||||
protocol: profile.protocol,
|
|
||||||
schemaArtifactRef: profile.schemaArtifactRef || "",
|
|
||||||
profileDigest: profile.profileDigest || "",
|
|
||||||
capabilities: profile.capabilities,
|
|
||||||
lifecycleState: "active",
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{view === "infrastructure" ? (
|
|
||||||
<InfrastructureView
|
|
||||||
workspace={workspace}
|
|
||||||
canManageCatalog={platformOwner}
|
|
||||||
canManageRoutes={capabilities.has("route.manage")}
|
|
||||||
onCreateEdge={() => setDialog("edge")}
|
|
||||||
onCreateRoute={() => setDialog("route")}
|
|
||||||
onActivateEdge={(edge) => mutateAndRefresh(() => ensureEdge({
|
|
||||||
edgeKey: edge.edgeKey,
|
|
||||||
displayName: edge.displayName,
|
|
||||||
deploymentRef: edge.deploymentRef,
|
|
||||||
lifecycleState: "active",
|
|
||||||
}))}
|
|
||||||
onActivateRoute={(route) => mutateAndRefresh(() => ensureRoute({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
routeKey: route.routeKey,
|
|
||||||
displayName: route.displayName,
|
|
||||||
edgeRef: route.edgeRef,
|
|
||||||
modelProfileRef: route.modelProfileRef,
|
|
||||||
listenerRef: route.listenerRef,
|
|
||||||
protocol: route.protocol,
|
|
||||||
direction: route.direction,
|
|
||||||
lifecycleState: "active",
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{view === "sessions" ? <SessionsView workspace={workspace} /> : null}
|
|
||||||
{view === "bindings" ? (
|
|
||||||
<BindingsView
|
|
||||||
workspace={workspace}
|
|
||||||
canManage={capabilities.has("binding.manage")}
|
|
||||||
onCreate={() => setDialog("binding")}
|
|
||||||
onRevoke={(binding) => revokeDeviceBinding({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
bindingRef: binding.bindingRef,
|
|
||||||
resolutionCode: "operator.revoked",
|
|
||||||
}).then(onRefresh).catch(onError)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{view === "commands" ? (
|
|
||||||
<CommandsView
|
|
||||||
workspace={workspace}
|
|
||||||
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{view === "audit" ? <AuditView workspace={workspace} /> : null}
|
|
||||||
{view === "access" ? (
|
|
||||||
<AccessView
|
|
||||||
workspace={workspace}
|
|
||||||
canManage={capabilities.has("access.manage")}
|
|
||||||
onCreate={() => setDialog("grant")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{view === "settings" ? (
|
|
||||||
<SettingsView
|
|
||||||
workspace={workspace}
|
|
||||||
canConfigure={capabilities.has("configuration.manage")}
|
|
||||||
onCreateConfiguration={() => setDialog("configuration")}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<AdapterPackageDialog
|
|
||||||
open={dialog === "adapter-package"}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<AdapterVersionDialog
|
|
||||||
open={dialog === "adapter-version"}
|
|
||||||
packages={workspace.adapterPackages}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<ModelProfileDialog
|
|
||||||
open={dialog === "model-profile"}
|
|
||||||
versions={workspace.adapterVersions}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<EdgeDialog
|
|
||||||
open={dialog === "edge"}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<RouteDialog
|
|
||||||
open={dialog === "route"}
|
|
||||||
workspace={workspace}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<BindingDialog
|
|
||||||
open={dialog === "binding"}
|
|
||||||
workspace={workspace}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<GrantDialog
|
|
||||||
open={dialog === "grant"}
|
|
||||||
projectRef={workspace.project.projectRef}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
<ConfigurationDialog
|
|
||||||
open={dialog === "configuration"}
|
|
||||||
workspace={workspace}
|
|
||||||
onClose={close}
|
|
||||||
onCreated={completed}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile, onActivateVersion, onActivateProfile }: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canManage: boolean;
|
|
||||||
onCreatePackage: () => void;
|
|
||||||
onCreateVersion: () => void;
|
|
||||||
onCreateProfile: () => void;
|
|
||||||
onActivateVersion: (version: AdapterVersionView) => void;
|
|
||||||
onActivateProfile: (profile: ModelProfileView) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<ControlToolbar
|
|
||||||
copy="Adapter packages и model profiles — глобальный versioned каталог. B2 здесь не является отдельным продуктом."
|
|
||||||
actions={canManage ? <>
|
|
||||||
<Button size="compact" onClick={onCreatePackage}>Пакет</Button>
|
|
||||||
<Button size="compact" onClick={onCreateVersion} disabled={!workspace.adapterPackages.length}>Версия</Button>
|
|
||||||
<Button size="compact" variant="primary" onClick={onCreateProfile} disabled={!workspace.adapterVersions.length}>Профиль</Button>
|
|
||||||
</> : null}
|
|
||||||
/>
|
|
||||||
<ControlSection title="Model profiles" count={workspace.modelProfiles.length}>
|
|
||||||
<ResourceGrid empty="В доступном каталоге пока нет model profiles.">
|
|
||||||
{workspace.modelProfiles.map((profile) => (
|
|
||||||
<ResourceCard
|
|
||||||
key={profile.modelProfileRef}
|
|
||||||
eyebrow={`${profile.vendor} · ${profile.deviceType}`}
|
|
||||||
title={`${profile.model}`}
|
|
||||||
description={`${profile.protocol} · ${profile.modelProfileRef}`}
|
|
||||||
status={profile.lifecycleState}
|
|
||||||
meta={profile.capabilities}
|
|
||||||
action={canManage && profile.lifecycleState === "draft" && profile.adapterVersionRef && profile.schemaArtifactRef && profile.profileDigest ? (
|
|
||||||
<Button size="compact" variant="primary" onClick={() => onActivateProfile(profile)}>Активировать</Button>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceGrid>
|
|
||||||
</ControlSection>
|
|
||||||
<ControlSection title="Adapter versions" count={workspace.adapterVersions.length}>
|
|
||||||
<ResourceGrid empty="Версии адаптеров не зарегистрированы.">
|
|
||||||
{workspace.adapterVersions.map((version) => (
|
|
||||||
<ResourceCard
|
|
||||||
key={version.adapterVersionRef}
|
|
||||||
eyebrow={version.contractVersion}
|
|
||||||
title={version.version}
|
|
||||||
description={version.runtimePackageRef}
|
|
||||||
status={version.lifecycleState}
|
|
||||||
meta={[shortDigest(version.contentDigest), ...version.capabilities]}
|
|
||||||
action={canManage && version.lifecycleState === "draft" ? (
|
|
||||||
<Button size="compact" variant="primary" onClick={() => onActivateVersion(version)}>Активировать</Button>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceGrid>
|
|
||||||
</ControlSection>
|
|
||||||
<ControlSection title="Adapter packages" count={workspace.adapterPackages.length}>
|
|
||||||
<ResourceGrid empty="Adapter packages не зарегистрированы.">
|
|
||||||
{workspace.adapterPackages.map((adapterPackage) => (
|
|
||||||
<ResourceCard
|
|
||||||
key={adapterPackage.adapterPackageRef}
|
|
||||||
eyebrow={adapterPackage.publisherRef}
|
|
||||||
title={adapterPackage.displayName}
|
|
||||||
description={adapterPackage.packageKey}
|
|
||||||
status={adapterPackage.lifecycleState}
|
|
||||||
meta={workspace.adapterVersions
|
|
||||||
.filter((version) => version.adapterPackageRef === adapterPackage.adapterPackageRef)
|
|
||||||
.map((version) => `${version.version} · ${version.lifecycleState}`)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceGrid>
|
|
||||||
</ControlSection>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute, onActivateEdge, onActivateRoute }: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canManageCatalog: boolean;
|
|
||||||
canManageRoutes: boolean;
|
|
||||||
onCreateEdge: () => void;
|
|
||||||
onCreateRoute: () => void;
|
|
||||||
onActivateEdge: (edge: EdgeView) => void;
|
|
||||||
onActivateRoute: (route: ProjectWorkspace["routes"][number]) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<ControlToolbar
|
|
||||||
copy="Edge — зарегистрированная внешняя роль. Route связывает проект, Edge, profile и логический listener без credentials."
|
|
||||||
actions={<>
|
|
||||||
{canManageCatalog ? <Button size="compact" onClick={onCreateEdge}>Новый Edge</Button> : null}
|
|
||||||
{canManageRoutes ? <Button size="compact" variant="primary" onClick={onCreateRoute} disabled={!workspace.edges.length || !workspace.modelProfiles.length}>Новый маршрут</Button> : null}
|
|
||||||
</>}
|
|
||||||
/>
|
|
||||||
<ControlSection title="Routes" count={workspace.routes.length}>
|
|
||||||
<ResourceGrid empty="Маршрутов в проекте пока нет.">
|
|
||||||
{workspace.routes.map((route) => (
|
|
||||||
<ResourceCard
|
|
||||||
key={route.routeRef}
|
|
||||||
eyebrow={`${route.protocol} · ${route.direction}`}
|
|
||||||
title={route.displayName}
|
|
||||||
description={`${route.edgeName} → ${route.profileName}`}
|
|
||||||
status={route.lifecycleState}
|
|
||||||
meta={[
|
|
||||||
route.listenerRef,
|
|
||||||
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
|
|
||||||
]}
|
|
||||||
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
|
|
||||||
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceGrid>
|
|
||||||
</ControlSection>
|
|
||||||
<ControlSection title="Edges" count={workspace.edges.length}>
|
|
||||||
<ResourceGrid empty="Доступных Edge registrations нет.">
|
|
||||||
{workspace.edges.map((edge) => (
|
|
||||||
<ResourceCard
|
|
||||||
key={edge.edgeRef}
|
|
||||||
eyebrow="DEVICE GATEWAY EDGE"
|
|
||||||
title={edge.displayName}
|
|
||||||
description={edge.edgeKey}
|
|
||||||
status={edge.lifecycleState}
|
|
||||||
meta={edge.deploymentRef ? [edge.deploymentRef] : []}
|
|
||||||
action={canManageCatalog && ["provisioning", "suspended"].includes(edge.lifecycleState) ? (
|
|
||||||
<Button size="compact" variant="primary" onClick={() => onActivateEdge(edge)}>Активировать</Button>
|
|
||||||
) : null}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceGrid>
|
|
||||||
</ControlSection>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SessionsView({ workspace }: { workspace: ProjectWorkspace }) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<ControlToolbar copy="Сессии принадлежат Gateway runtime. Device Manager только читает bounded presence/counter projection." />
|
|
||||||
<ResourceList empty="Gateway sessions пока не наблюдались.">
|
|
||||||
{workspace.sessions.map((session) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={session.sessionRef}
|
|
||||||
title={session.deviceName || "Неидентифицированная сессия"}
|
|
||||||
description={`${session.routeName} · ${session.protocol} · ${formatDate(session.lastSeenAt)}`}
|
|
||||||
status={session.lifecycleState}
|
|
||||||
trailing={`${session.frameCount} frames · ${formatBytes(session.byteCount)}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canManage: boolean;
|
|
||||||
onCreate: () => void;
|
|
||||||
onRevoke: (binding: BindingView) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<ControlToolbar
|
|
||||||
copy="Binding создаёт только source approval. Active появится лишь после отдельного external proof от целевой системы."
|
|
||||||
actions={canManage ? <Button variant="primary" onClick={onCreate} disabled={!workspace.collections.length && !workspace.devices.length}>Новый binding</Button> : null}
|
|
||||||
/>
|
|
||||||
<ResourceList empty="Data bindings пока не создавались.">
|
|
||||||
{workspace.bindings.map((binding) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={binding.bindingRef}
|
|
||||||
title={binding.displayName}
|
|
||||||
description={`${binding.source.displayName} → ${binding.target.kind}:${binding.target.ref}`}
|
|
||||||
status={binding.lifecycleState}
|
|
||||||
trailing={binding.lifecycleState !== "revoked" && canManage ? (
|
|
||||||
<Button size="compact" variant="danger" onClick={() => onRevoke(binding)}>Отозвать</Button>
|
|
||||||
) : binding.capabilities.join(", ")}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canDispatch: boolean;
|
|
||||||
onRefresh: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const supportedDevices = workspace.devices.filter(
|
|
||||||
(device) => device.modelProfileRef === "arusnavi.b2.internal.v1"
|
|
||||||
&& !["suspended", "retired"].includes(device.lifecycleState),
|
|
||||||
);
|
|
||||||
const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? "");
|
|
||||||
const [accessCode, setAccessCode] = useState("");
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const enabled = workspace.policies.commandTransport === "typed-service-ping-v1";
|
|
||||||
useEffect(() => {
|
|
||||||
if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) {
|
|
||||||
setDeviceRef(supportedDevices[0]?.deviceRef ?? "");
|
|
||||||
}
|
|
||||||
}, [deviceRef, supportedDevices]);
|
|
||||||
const submit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await sendServicePing({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
deviceRef,
|
|
||||||
accessCode,
|
|
||||||
expiresInSeconds: 300,
|
|
||||||
});
|
|
||||||
setAccessCode("");
|
|
||||||
await onRefresh();
|
|
||||||
} catch (reason) {
|
|
||||||
onError(reason);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<div className="device-control-command-policy">
|
|
||||||
<Icon name={enabled ? "check" : "lock"} />
|
|
||||||
<div>
|
|
||||||
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
|
|
||||||
<p>{enabled
|
|
||||||
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
|
|
||||||
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
|
|
||||||
</div>
|
|
||||||
{enabled ? (
|
|
||||||
<form className="device-control-command-form" onSubmit={submit}>
|
|
||||||
<Select
|
|
||||||
label="B2 трекер"
|
|
||||||
value={deviceRef}
|
|
||||||
onChange={setDeviceRef}
|
|
||||||
options={supportedDevices.map((device) => ({
|
|
||||||
value: device.deviceRef,
|
|
||||||
label: device.displayName,
|
|
||||||
description: device.session?.state || device.lifecycleState,
|
|
||||||
}))}
|
|
||||||
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Код устройства"
|
|
||||||
type="password"
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="off"
|
|
||||||
value={accessCode}
|
|
||||||
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
|
||||||
pattern="[0-9]{6}"
|
|
||||||
minLength={6}
|
|
||||||
maxLength={6}
|
|
||||||
required
|
|
||||||
disabled={!canDispatch || submitting}
|
|
||||||
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
|
|
||||||
>
|
|
||||||
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
|
|
||||||
{workspace.commands.map((command) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={command.commandRef}
|
|
||||||
title={`${command.commandType} · ${command.deviceName}`}
|
|
||||||
description={`${command.riskClass} · expires ${formatDate(command.expiresAt)}`}
|
|
||||||
status={command.lifecycleState}
|
|
||||||
trailing={command.terminalReasonCode || command.commandKey}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuditView({ workspace }: { workspace: ProjectWorkspace }) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<ControlToolbar copy="Показывается immutable metadata projection. Audit payload намеренно не выдаётся в браузер." />
|
|
||||||
<ResourceList empty="Audit events для проекта отсутствуют.">
|
|
||||||
{workspace.auditEvents.map((event) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={event.auditEventRef}
|
|
||||||
title={event.eventType}
|
|
||||||
description={`${event.actorRef} · ${formatDate(event.occurredAt)}`}
|
|
||||||
status="recorded"
|
|
||||||
trailing={event.deviceRef || event.discoveryRef || "project"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccessView({ workspace, canManage, onCreate }: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canManage: boolean;
|
|
||||||
onCreate: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<ControlToolbar
|
|
||||||
copy="Hub задаёт потолок, а Device Project grant — конкретную роль. Direct user grant имеет приоритет над group grants."
|
|
||||||
actions={canManage ? <Button variant="primary" onClick={onCreate}>Добавить доступ</Button> : null}
|
|
||||||
/>
|
|
||||||
<ResourceList empty="Project grants недоступны или ещё не созданы.">
|
|
||||||
{workspace.grants.map((grant) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={grant.grantRef}
|
|
||||||
title={grant.principalRef}
|
|
||||||
description={`${grant.principalKind} · ${grant.projectRole}`}
|
|
||||||
status={grant.lifecycleState}
|
|
||||||
trailing={grant.capabilityDeny.length ? `deny: ${grant.capabilityDeny.join(", ")}` : "role capabilities"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsView({ workspace, canConfigure, onCreateConfiguration }: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canConfigure: boolean;
|
|
||||||
onCreateConfiguration: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ControlStack>
|
|
||||||
<div className="device-control-policy-grid">
|
|
||||||
<PolicyCard label="Identifiers" value={workspace.policies.identifierProjection} />
|
|
||||||
<PolicyCard label="Audit payload" value={workspace.policies.auditPayloadProjection} />
|
|
||||||
<PolicyCard label="Command API" value={workspace.policies.commandPlanningApi} />
|
|
||||||
</div>
|
|
||||||
<ControlToolbar
|
|
||||||
copy="Configuration revisions immutable. Desired и applied — разные указатели; создание desired не означает применение устройством."
|
|
||||||
actions={canConfigure ? <Button variant="primary" onClick={onCreateConfiguration} disabled={!workspace.devices.length}>Новая desired revision</Button> : null}
|
|
||||||
/>
|
|
||||||
<ResourceList empty="Configuration state пока отсутствует.">
|
|
||||||
{workspace.configurationStates.map((state) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={state.deviceRef}
|
|
||||||
title={state.deviceName}
|
|
||||||
description={`desired: ${shortRef(state.desiredConfigurationRevisionRef)} · applied: ${shortRef(state.appliedConfigurationRevisionRef)}`}
|
|
||||||
status={state.appliedConfigurationRevisionRef === state.desiredConfigurationRevisionRef ? "applied" : "pending"}
|
|
||||||
trailing={formatDate(state.updatedAt)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
<ControlSection title="Immutable revisions" count={workspace.configurationRevisions.length}>
|
|
||||||
<ResourceList empty="Configuration revisions отсутствуют.">
|
|
||||||
{workspace.configurationRevisions.map((revision) => (
|
|
||||||
<ResourceRow
|
|
||||||
key={revision.configurationRevisionRef}
|
|
||||||
title={`${revision.deviceName} · revision ${revision.revisionNumber}`}
|
|
||||||
description={revision.changeSummary || revision.modelProfileRef}
|
|
||||||
status="immutable"
|
|
||||||
trailing={shortDigest(revision.configurationDigest)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ResourceList>
|
|
||||||
</ControlSection>
|
|
||||||
</ControlStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdapterPackageDialog(props: DialogBaseProps) {
|
|
||||||
const [packageKey, setPackageKey] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [publisherRef, setPublisherRef] = useState("");
|
|
||||||
return <FormWindow {...props} id="adapter-package-form" title="Adapter package" submit={async () => {
|
|
||||||
await ensureAdapterPackage({ packageKey, displayName, publisherRef, lifecycleState: "active" });
|
|
||||||
}}>
|
|
||||||
<KeyField label="Ключ пакета" value={packageKey} onChange={setPackageKey} />
|
|
||||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
|
||||||
<TextField label="Publisher ref" value={publisherRef} onChange={(event) => setPublisherRef(event.target.value)} required />
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdapterVersionDialog({ packages, ...props }: DialogBaseProps & { packages: AdapterPackageView[] }) {
|
|
||||||
const [packageRef, setPackageRef] = useState(packages[0]?.adapterPackageRef ?? "");
|
|
||||||
const [version, setVersion] = useState("");
|
|
||||||
const [runtimeRef, setRuntimeRef] = useState("");
|
|
||||||
const [digest, setDigest] = useState("");
|
|
||||||
const [contractVersion, setContractVersion] = useState("");
|
|
||||||
const [capabilities, setCapabilities] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
if (!packages.some((item) => item.adapterPackageRef === packageRef)) {
|
|
||||||
setPackageRef(packages[0]?.adapterPackageRef ?? "");
|
|
||||||
}
|
|
||||||
}, [packageRef, packages]);
|
|
||||||
return <FormWindow {...props} id="adapter-version-form" title="Версия адаптера" disabled={!packageRef} submit={async () => {
|
|
||||||
await registerAdapterVersion({
|
|
||||||
adapterPackageRef: packageRef,
|
|
||||||
version,
|
|
||||||
runtimePackageRef: runtimeRef,
|
|
||||||
contentDigest: digest,
|
|
||||||
contractVersion,
|
|
||||||
capabilities: commaList(capabilities),
|
|
||||||
lifecycleState: "draft",
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<Select label="Adapter package" value={packageRef} onChange={setPackageRef} options={packages.map((item) => ({ value: item.adapterPackageRef, label: item.displayName }))} />
|
|
||||||
<TextField label="SemVer" value={version} onChange={(event) => setVersion(event.target.value)} required placeholder="1.0.0" />
|
|
||||||
<TextField label="Runtime artifact ref" value={runtimeRef} onChange={(event) => setRuntimeRef(event.target.value)} required />
|
|
||||||
<TextField label="Content digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
|
||||||
<TextField label="Contract version" value={contractVersion} onChange={(event) => setContractVersion(event.target.value)} required />
|
|
||||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="Через запятую" />
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelProfileDialog({ versions, ...props }: DialogBaseProps & { versions: AdapterVersionView[] }) {
|
|
||||||
const [versionRef, setVersionRef] = useState(versions[0]?.adapterVersionRef ?? "");
|
|
||||||
const [profileRef, setProfileRef] = useState("");
|
|
||||||
const [schemaVersion, setSchemaVersion] = useState("");
|
|
||||||
const [vendor, setVendor] = useState("");
|
|
||||||
const [model, setModel] = useState("");
|
|
||||||
const [deviceType, setDeviceType] = useState("");
|
|
||||||
const [protocol, setProtocol] = useState("");
|
|
||||||
const [schemaRef, setSchemaRef] = useState("");
|
|
||||||
const [digest, setDigest] = useState("");
|
|
||||||
const [capabilities, setCapabilities] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
if (!versions.some((item) => item.adapterVersionRef === versionRef)) {
|
|
||||||
setVersionRef(versions[0]?.adapterVersionRef ?? "");
|
|
||||||
}
|
|
||||||
}, [versionRef, versions]);
|
|
||||||
return <FormWindow {...props} id="model-profile-form" title="Model profile" disabled={!versionRef} submit={async () => {
|
|
||||||
await registerModelProfile({
|
|
||||||
adapterVersionRef: versionRef,
|
|
||||||
profileRef,
|
|
||||||
schemaVersion,
|
|
||||||
vendor,
|
|
||||||
model,
|
|
||||||
deviceType,
|
|
||||||
protocol: protocol.toUpperCase(),
|
|
||||||
schemaArtifactRef: schemaRef,
|
|
||||||
profileDigest: digest,
|
|
||||||
capabilities: commaList(capabilities),
|
|
||||||
lifecycleState: "draft",
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<Select label="Adapter version" value={versionRef} onChange={setVersionRef} options={versions.map((item) => ({ value: item.adapterVersionRef, label: item.version, description: item.runtimePackageRef }))} />
|
|
||||||
<TextField label="Profile ref" value={profileRef} onChange={(event) => setProfileRef(event.target.value)} required />
|
|
||||||
<TextField label="Schema version" value={schemaVersion} onChange={(event) => setSchemaVersion(event.target.value)} required />
|
|
||||||
<TextField label="Vendor" value={vendor} onChange={(event) => setVendor(event.target.value)} required />
|
|
||||||
<TextField label="Model" value={model} onChange={(event) => setModel(event.target.value)} required />
|
|
||||||
<KeyField label="Device type" value={deviceType} onChange={setDeviceType} />
|
|
||||||
<TextField label="Protocol" value={protocol} onChange={(event) => setProtocol(event.target.value)} required />
|
|
||||||
<TextField label="Schema artifact ref" value={schemaRef} onChange={(event) => setSchemaRef(event.target.value)} required />
|
|
||||||
<TextField label="Profile digest" value={digest} onChange={(event) => setDigest(event.target.value)} required placeholder="sha256:…" />
|
|
||||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} />
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EdgeDialog(props: DialogBaseProps) {
|
|
||||||
const [edgeKey, setEdgeKey] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [deploymentRef, setDeploymentRef] = useState("");
|
|
||||||
return <FormWindow {...props} id="edge-form" title="Новый Edge" submit={async () => {
|
|
||||||
await ensureEdge({ edgeKey, displayName, deploymentRef: deploymentRef || null, lifecycleState: "provisioning" });
|
|
||||||
}}>
|
|
||||||
<KeyField label="Edge key" value={edgeKey} onChange={setEdgeKey} />
|
|
||||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
|
||||||
<TextField label="Deployment ref" value={deploymentRef} onChange={(event) => setDeploymentRef(event.target.value)} description="Opaque artifact/deployment reference, не адрес и не credential." />
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
|
||||||
const [routeKey, setRouteKey] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [edgeRef, setEdgeRef] = useState(workspace.edges[0]?.edgeRef ?? "");
|
|
||||||
const [profileRef, setProfileRef] = useState(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
|
||||||
const [listenerRef, setListenerRef] = useState("");
|
|
||||||
const profile = workspace.modelProfiles.find((item) => item.modelProfileRef === profileRef);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspace.edges.some((item) => item.edgeRef === edgeRef)) {
|
|
||||||
setEdgeRef(workspace.edges[0]?.edgeRef ?? "");
|
|
||||||
}
|
|
||||||
if (!workspace.modelProfiles.some((item) => item.modelProfileRef === profileRef)) {
|
|
||||||
setProfileRef(workspace.modelProfiles[0]?.modelProfileRef ?? "");
|
|
||||||
}
|
|
||||||
}, [edgeRef, profileRef, workspace.edges, workspace.modelProfiles]);
|
|
||||||
return <FormWindow {...props} id="route-form" title="Новый маршрут" disabled={!edgeRef || !profileRef} submit={async () => {
|
|
||||||
await ensureRoute({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
routeKey,
|
|
||||||
displayName,
|
|
||||||
edgeRef,
|
|
||||||
modelProfileRef: profileRef,
|
|
||||||
listenerRef,
|
|
||||||
protocol: profile?.protocol || "INTERNAL",
|
|
||||||
direction: "telemetry",
|
|
||||||
lifecycleState: "draft",
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<KeyField label="Route key" value={routeKey} onChange={setRouteKey} />
|
|
||||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
|
||||||
<Select label="Edge" value={edgeRef} onChange={setEdgeRef} options={workspace.edges.map((item) => ({ value: item.edgeRef, label: item.displayName, description: item.lifecycleState }))} />
|
|
||||||
<Select label="Model profile" value={profileRef} onChange={setProfileRef} options={workspace.modelProfiles.map((item) => ({ value: item.modelProfileRef, label: `${item.vendor} ${item.model}`, description: item.protocol }))} />
|
|
||||||
<TextField label="Listener ref" value={listenerRef} onChange={(event) => setListenerRef(event.target.value)} required />
|
|
||||||
<p className="device-manager-card-copy">Маршрут создаётся draft. Его activation остаётся отдельным осознанным изменением данных.</p>
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BindingDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
|
||||||
const sources = useMemo(() => [
|
|
||||||
...workspace.collections.map((item) => ({ value: `collection|${item.collectionRef}`, label: item.name })),
|
|
||||||
...workspace.devices.map((item) => ({ value: `device|${item.deviceRef}`, label: item.displayName })),
|
|
||||||
], [workspace]);
|
|
||||||
const [sourceValue, setSourceValue] = useState(sources[0]?.value ?? "");
|
|
||||||
const [bindingKey, setBindingKey] = useState("");
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
|
||||||
const [targetKind, setTargetKind] = useState("");
|
|
||||||
const [targetRef, setTargetRef] = useState("");
|
|
||||||
const [capabilities, setCapabilities] = useState("observe");
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sources.some((item) => item.value === sourceValue)) {
|
|
||||||
setSourceValue(sources[0]?.value ?? "");
|
|
||||||
}
|
|
||||||
}, [sourceValue, sources]);
|
|
||||||
return <FormWindow {...props} id="binding-form" title="Новый data binding" disabled={!sourceValue} submit={async () => {
|
|
||||||
const [kind, ref] = sourceValue.split("|");
|
|
||||||
await ensureDeviceBinding({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
bindingKey,
|
|
||||||
displayName,
|
|
||||||
source: { kind: kind as "device" | "collection", ref },
|
|
||||||
targetKind,
|
|
||||||
targetRef,
|
|
||||||
capabilities: commaList(capabilities),
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<Select label="Source" value={sourceValue} onChange={setSourceValue} options={sources} />
|
|
||||||
<KeyField label="Binding key" value={bindingKey} onChange={setBindingKey} />
|
|
||||||
<TextField label="Название" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required />
|
|
||||||
<TextField label="Target kind" value={targetKind} onChange={(event) => setTargetKind(event.target.value)} required placeholder="foundry.application" />
|
|
||||||
<TextField label="Target ref" value={targetRef} onChange={(event) => setTargetRef(event.target.value)} required />
|
|
||||||
<TextField label="Capabilities" value={capabilities} onChange={(event) => setCapabilities(event.target.value)} description="observe, inspect, configure, command" required />
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function GrantDialog({ projectRef, ...props }: DialogBaseProps & { projectRef: string }) {
|
|
||||||
const [principalKind, setPrincipalKind] = useState<"user" | "group">("user");
|
|
||||||
const [principalRef, setPrincipalRef] = useState("");
|
|
||||||
const [role, setRole] = useState("viewer");
|
|
||||||
const [allow, setAllow] = useState("");
|
|
||||||
const [deny, setDeny] = useState("");
|
|
||||||
return <FormWindow {...props} id="grant-form" title="Project access" submit={async () => {
|
|
||||||
await upsertProjectGrant({
|
|
||||||
projectRef,
|
|
||||||
principalKind,
|
|
||||||
principalRef,
|
|
||||||
projectRole: role,
|
|
||||||
capabilityAllow: commaList(allow),
|
|
||||||
capabilityDeny: commaList(deny),
|
|
||||||
lifecycleState: "active",
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<Select label="Principal type" value={principalKind} onChange={setPrincipalKind} options={[{ value: "user", label: "User" }, { value: "group", label: "Group" }]} />
|
|
||||||
<TextField label="Principal ref" value={principalRef} onChange={(event) => setPrincipalRef(event.target.value)} required />
|
|
||||||
<Select label="Project role" value={role} onChange={setRole} options={["viewer", "operator", "engineer", "admin", "owner"].map((value) => ({ value, label: value, disabled: value === "owner" && principalKind === "group" }))} />
|
|
||||||
<TextField label="Capability allow" value={allow} onChange={(event) => setAllow(event.target.value)} description="Опциональные точечные добавления" />
|
|
||||||
<TextField label="Capability deny" value={deny} onChange={(event) => setDeny(event.target.value)} description="Deny имеет приоритет" />
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfigurationDialog({ workspace, ...props }: DialogBaseProps & { workspace: ProjectWorkspace }) {
|
|
||||||
const [deviceRef, setDeviceRef] = useState(workspace.devices[0]?.deviceRef ?? "");
|
|
||||||
const [configuration, setConfiguration] = useState("{\n \"reporting_interval_seconds\": 30\n}");
|
|
||||||
const [summary, setSummary] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
if (!workspace.devices.some((item) => item.deviceRef === deviceRef)) {
|
|
||||||
setDeviceRef(workspace.devices[0]?.deviceRef ?? "");
|
|
||||||
}
|
|
||||||
}, [deviceRef, workspace.devices]);
|
|
||||||
return <FormWindow {...props} id="configuration-form" title="Новая desired configuration" disabled={!deviceRef} submit={async () => {
|
|
||||||
const parsed = JSON.parse(configuration) as Record<string, unknown>;
|
|
||||||
const created = await createConfigurationRevision({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
deviceRef,
|
|
||||||
configuration: parsed,
|
|
||||||
changeSummary: summary || null,
|
|
||||||
});
|
|
||||||
await setDesiredConfiguration({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
deviceRef,
|
|
||||||
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
<Select label="Device" value={deviceRef} onChange={setDeviceRef} options={workspace.devices.map((item) => ({ value: item.deviceRef, label: item.displayName, description: item.modelProfileRef }))} />
|
|
||||||
<TextAreaField label="Configuration JSON" value={configuration} onChange={(event) => setConfiguration(event.target.value)} required />
|
|
||||||
<TextAreaField label="Change summary" value={summary} onChange={(event) => setSummary(event.target.value)} />
|
|
||||||
<p className="device-manager-card-copy">Secret-like keys и значения будут отклонены Core. Сохранение desired не выставляет applied.</p>
|
|
||||||
</FormWindow>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DialogBaseProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onCreated: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormWindow({ open, onClose, onCreated, onError, id, title, submit, disabled = false, children }: DialogBaseProps & {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
submit: () => Promise<void>;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const handleSubmit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await submit();
|
|
||||||
await onCreated();
|
|
||||||
} catch (reason) {
|
|
||||||
onError(reason);
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Window open={open} title={title} onClose={onClose} footer={
|
|
||||||
<WindowFooterActions>
|
|
||||||
<Button variant="ghost" onClick={onClose}>Отмена</Button>
|
|
||||||
<Button type="submit" form={id} variant="primary" disabled={disabled || pending}>{pending ? "Сохраняем…" : "Сохранить"}</Button>
|
|
||||||
</WindowFooterActions>
|
|
||||||
}>
|
|
||||||
<form id={id} className="device-manager-form device-control-form" onSubmit={handleSubmit}>{children}</form>
|
|
||||||
</Window>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function KeyField({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
|
||||||
return <TextField label={label} value={value} onChange={(event) => onChange(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ControlStack({ children }: { children: ReactNode }) {
|
|
||||||
return <div className="device-manager-stack device-control-stack">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ControlToolbar({ copy, actions }: { copy: string; actions?: ReactNode }) {
|
|
||||||
return <div className="device-manager-panel-toolbar device-control-toolbar"><p>{copy}</p>{actions ? <div className="device-control-toolbar__actions">{actions}</div> : null}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ControlSection({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
|
||||||
return <section className="device-control-section"><div className="device-control-section__title"><h3>{title}</h3><StatusBadge>{count}</StatusBadge></div>{children}</section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceGrid({ children, empty }: { children: ReactNode; empty: string }) {
|
|
||||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
|
||||||
return hasChildren ? <div className="device-control-resource-grid">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceCard({ eyebrow, title, description, status, meta, action = null }: { eyebrow: string; title: string; description: string; status: string; meta: string[]; action?: ReactNode }) {
|
|
||||||
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{action}</>}>
|
|
||||||
{meta.length ? <div className="device-manager-capabilities">{meta.map((item) => <span key={item}>{item}</span>)}</div> : <p className="device-manager-card-copy">Metadata-only projection</p>}
|
|
||||||
</SettingsCard>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceList({ children, empty }: { children: ReactNode; empty: string }) {
|
|
||||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
|
||||||
return hasChildren ? <div className="device-manager-entity-list">{children}</div> : <div className="device-manager-panel-empty">{empty}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ResourceRow({ title, description, status, trailing }: { title: string; description: string; status: string; trailing: ReactNode }) {
|
|
||||||
return <GlassSurface className="device-manager-entity device-control-row" padding="md" tone="soft">
|
|
||||||
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
|
|
||||||
<span className="device-manager-entity__body"><strong>{title}</strong><small>{description}</small></span>
|
|
||||||
<span className="device-control-row__status"><StatusBadge tone={statusTone(status)}>{status}</StatusBadge>{typeof trailing === "string" ? <small>{trailing}</small> : trailing}</span>
|
|
||||||
</GlassSurface>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PolicyCard({ label, value }: { label: string; value: string }) {
|
|
||||||
return <GlassSurface padding="md" tone="soft"><small>{label}</small><strong>{value}</strong></GlassSurface>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function commaList(value: string) {
|
|
||||||
return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTone(status: string): "neutral" | "success" | "warning" | "danger" {
|
|
||||||
if (["active", "online", "verified", "applied", "recorded", "immutable"].includes(status)) return "success";
|
|
||||||
if (["failed", "rejected", "revoked", "retired"].includes(status)) return "danger";
|
|
||||||
if (["draft", "provisioning", "pending", "pending_external_approval", "unknown", "disabled"].includes(status)) return "warning";
|
|
||||||
return "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
|
||||||
if (!value) return "—";
|
|
||||||
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(value: number) {
|
|
||||||
if (value < 1024) return `${value} B`;
|
|
||||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
|
||||||
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortRef(value: string | null) {
|
|
||||||
return value ? `${value.slice(0, 18)}…` : "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortDigest(value: string) {
|
|
||||||
return `${value.slice(0, 15)}…${value.slice(-8)}`;
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
GlassSurface,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
StatusBadge,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
import {
|
|
||||||
accessLabel,
|
|
||||||
getDeviceProfileCatalog,
|
|
||||||
type DeviceFieldAccess,
|
|
||||||
type DeviceProfileField,
|
|
||||||
} from "./deviceProfileCatalog";
|
|
||||||
import type {
|
|
||||||
DeviceView,
|
|
||||||
ProjectWorkspace,
|
|
||||||
SessionView,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export function DeviceInventoryView({
|
|
||||||
workspace,
|
|
||||||
canEnroll,
|
|
||||||
onCreateEnrollment,
|
|
||||||
onPoll,
|
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
canEnroll: boolean;
|
|
||||||
onCreateEnrollment: () => void;
|
|
||||||
onPoll: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const [selectedDeviceRef, setSelectedDeviceRef] = useState<string | null>(null);
|
|
||||||
const selectedDevice = workspace.devices.find(
|
|
||||||
(device) => device.deviceRef === selectedDeviceRef,
|
|
||||||
) ?? null;
|
|
||||||
const activeRouteAvailable = workspace.routes.some(
|
|
||||||
(route) => route.lifecycleState === "active",
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedDeviceRef(null);
|
|
||||||
}, [workspace.project.projectRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedDeviceRef && !selectedDevice) setSelectedDeviceRef(null);
|
|
||||||
}, [selectedDevice, selectedDeviceRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedDeviceRef) return undefined;
|
|
||||||
const poll = () => {
|
|
||||||
if (document.visibilityState === "visible") onPoll().catch(onError);
|
|
||||||
};
|
|
||||||
const timer = window.setInterval(poll, 5_000);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [onError, onPoll, selectedDeviceRef]);
|
|
||||||
|
|
||||||
if (selectedDevice) {
|
|
||||||
return (
|
|
||||||
<DeviceDetailView
|
|
||||||
device={selectedDevice}
|
|
||||||
workspace={workspace}
|
|
||||||
onBack={() => setSelectedDeviceRef(null)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="device-inventory">
|
|
||||||
<div className="device-manager-panel-toolbar device-inventory__toolbar">
|
|
||||||
<div>
|
|
||||||
<strong>Реестр устройств</strong>
|
|
||||||
<p>{workspace.devices.length} зарегистрировано в проекте</p>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
label="Подключить устройство"
|
|
||||||
disabled={!canEnroll || !activeRouteAvailable}
|
|
||||||
onClick={onCreateEnrollment}
|
|
||||||
>
|
|
||||||
<Icon name="plus" size={18} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!workspace.devices.length ? (
|
|
||||||
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
|
|
||||||
<Icon name="inbox" size={24} />
|
|
||||||
<h3>В проекте пока нет устройств</h3>
|
|
||||||
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому enrollment-процессу.</p>
|
|
||||||
</GlassSurface>
|
|
||||||
) : (
|
|
||||||
<div className="device-inventory-table" role="table" aria-label="Устройства проекта">
|
|
||||||
<div className="device-inventory-table__head" role="row">
|
|
||||||
<span role="columnheader">Устройство</span>
|
|
||||||
<span role="columnheader">Профиль</span>
|
|
||||||
<span role="columnheader">Идентификатор</span>
|
|
||||||
<span role="columnheader">Канал</span>
|
|
||||||
<span role="columnheader">Последний пакет</span>
|
|
||||||
<span aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
{workspace.devices.map((device) => {
|
|
||||||
const session = latestSession(workspace, device);
|
|
||||||
const online = session?.lifecycleState === "online" || device.session?.state === "online";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={device.deviceRef}
|
|
||||||
type="button"
|
|
||||||
className="device-inventory-row"
|
|
||||||
role="row"
|
|
||||||
onClick={() => setSelectedDeviceRef(device.deviceRef)}
|
|
||||||
>
|
|
||||||
<span className="device-inventory-row__device" role="cell">
|
|
||||||
<span className="device-inventory-row__icon"><Icon name="apps" size={17} /></span>
|
|
||||||
<span><strong>{device.displayName}</strong><small>{device.deviceKey || "ключ не назначен"}</small></span>
|
|
||||||
</span>
|
|
||||||
<span role="cell">{profileLabel(workspace, device)}</span>
|
|
||||||
<span role="cell">{device.identifier?.masked || "не назначен"}</span>
|
|
||||||
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
|
|
||||||
<span role="cell">{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</span>
|
|
||||||
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceDetailView({
|
|
||||||
device,
|
|
||||||
workspace,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
device: DeviceView;
|
|
||||||
workspace: ProjectWorkspace;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
|
|
||||||
const [activeSectionId, setActiveSectionId] = useState(catalog.sections[0]?.id ?? "passport");
|
|
||||||
const detailRef = useRef<HTMLDivElement>(null);
|
|
||||||
const profile = workspace.modelProfiles.find(
|
|
||||||
(item) => item.modelProfileRef === device.modelProfileRef,
|
|
||||||
) ?? null;
|
|
||||||
const session = latestSession(workspace, device);
|
|
||||||
const configurationState = workspace.configurationStates.find(
|
|
||||||
(item) => item.deviceRef === device.deviceRef,
|
|
||||||
) ?? null;
|
|
||||||
const context = useMemo(() => ({
|
|
||||||
device,
|
|
||||||
profile,
|
|
||||||
session,
|
|
||||||
configurationState,
|
|
||||||
reported: device.reported ?? {},
|
|
||||||
policies: {
|
|
||||||
...workspace.policies,
|
|
||||||
firmwareUpdate: "blocked",
|
|
||||||
},
|
|
||||||
}), [configurationState, device, profile, session, workspace.policies]);
|
|
||||||
const activeSection = catalog.sections.find(
|
|
||||||
(section) => section.id === activeSectionId,
|
|
||||||
) ?? catalog.sections[0];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveSectionId(catalog.sections[0]?.id ?? "passport");
|
|
||||||
}, [catalog.profileRef, device.deviceRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const panelBody = detailRef.current?.closest<HTMLElement>(".nodedc-application-panel__body");
|
|
||||||
if (panelBody) panelBody.scrollTop = 0;
|
|
||||||
}, [activeSectionId, device.deviceRef]);
|
|
||||||
|
|
||||||
if (!activeSection) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={detailRef} className="device-detail">
|
|
||||||
<div className="device-detail__header">
|
|
||||||
<IconButton label="Вернуться к списку устройств" onClick={onBack}>
|
|
||||||
<Icon name="chevron-left" size={18} />
|
|
||||||
</IconButton>
|
|
||||||
<div className="device-detail__identity">
|
|
||||||
<small>{catalog.vendor} · {catalog.model}</small>
|
|
||||||
<h2>{device.displayName}</h2>
|
|
||||||
<p>{device.identifier?.masked || "Идентификатор не назначен"} · {device.modelProfileRef}</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge tone={session?.lifecycleState === "online" ? "success" : "neutral"}>
|
|
||||||
{session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
|
|
||||||
</StatusBadge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="device-detail__legend" aria-label="Режимы доступа">
|
|
||||||
<AccessBadge access="read-only" />
|
|
||||||
<AccessBadge access="managed" />
|
|
||||||
<AccessBadge access="protected" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="device-detail__layout">
|
|
||||||
<nav className="device-detail__navigation" aria-label="Разделы устройства">
|
|
||||||
{catalog.sections.map((section) => (
|
|
||||||
<button
|
|
||||||
key={section.id}
|
|
||||||
type="button"
|
|
||||||
className="device-detail__navigation-item"
|
|
||||||
data-active={section.id === activeSection.id ? "true" : undefined}
|
|
||||||
aria-current={section.id === activeSection.id ? "page" : undefined}
|
|
||||||
onClick={() => setActiveSectionId(section.id)}
|
|
||||||
>
|
|
||||||
<span>{section.label}</span>
|
|
||||||
<small>{accessLabel(section.access)}</small>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<section className="device-detail-section">
|
|
||||||
<div className="device-detail-section__heading">
|
|
||||||
<div>
|
|
||||||
<span>{catalog.title}</span>
|
|
||||||
<h3>{activeSection.title}</h3>
|
|
||||||
<p>{activeSection.description}</p>
|
|
||||||
</div>
|
|
||||||
<AccessBadge access={activeSection.access} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AccessNotice
|
|
||||||
access={activeSection.access}
|
|
||||||
commandTransport={workspace.policies.commandTransport}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="device-detail-fields">
|
|
||||||
{activeSection.fields.map((item) => {
|
|
||||||
const access = item.access ?? activeSection.access;
|
|
||||||
const value = readPath(context, item.path);
|
|
||||||
return (
|
|
||||||
<div key={item.key} className="device-detail-field" data-access={access}>
|
|
||||||
<div className="device-detail-field__label">
|
|
||||||
<span>{item.label}</span>
|
|
||||||
{access !== activeSection.access ? <AccessBadge access={access} compact /> : null}
|
|
||||||
</div>
|
|
||||||
<strong>{formatFieldValue(value, item)}</strong>
|
|
||||||
{item.description ? <small>{item.description}</small> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="device-detail-section__state">
|
|
||||||
<span>Desired</span>
|
|
||||||
<strong>{configurationState?.desiredConfigurationRevisionRef || "Не задано"}</strong>
|
|
||||||
<span>Applied</span>
|
|
||||||
<strong>{configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}</strong>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
|
|
||||||
const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
|
|
||||||
return <StatusBadge className={compact ? "device-access-badge--compact" : undefined} tone={tone}>{accessLabel(access)}</StatusBadge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AccessNotice({
|
|
||||||
access,
|
|
||||||
commandTransport,
|
|
||||||
}: {
|
|
||||||
access: DeviceFieldAccess;
|
|
||||||
commandTransport: ProjectWorkspace["policies"]["commandTransport"];
|
|
||||||
}) {
|
|
||||||
if (access === "read-only") {
|
|
||||||
return <div className="device-detail-notice" data-access="read-only"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></div>;
|
|
||||||
}
|
|
||||||
if (access === "protected") {
|
|
||||||
return <div className="device-detail-notice" data-access="protected"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></div>;
|
|
||||||
}
|
|
||||||
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Настройка управляется через типизированный command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
|
|
||||||
const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef);
|
|
||||||
return sessions.sort((left, right) => {
|
|
||||||
if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1;
|
|
||||||
if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1;
|
|
||||||
return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || ""));
|
|
||||||
})[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function profileLabel(workspace: ProjectWorkspace, device: DeviceView) {
|
|
||||||
const profile = workspace.modelProfiles.find(
|
|
||||||
(item) => item.modelProfileRef === device.modelProfileRef,
|
|
||||||
);
|
|
||||||
return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPath(input: unknown, path: string): unknown {
|
|
||||||
return path.split(".").reduce<unknown>((value, key) => {
|
|
||||||
if (!value || typeof value !== "object") return undefined;
|
|
||||||
return (value as Record<string, unknown>)[key];
|
|
||||||
}, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFieldValue(value: unknown, item: DeviceProfileField) {
|
|
||||||
if (value === undefined || value === null || value === "") return "Нет данных";
|
|
||||||
if (item.sensitive) return "Задано · значение скрыто";
|
|
||||||
if (item.valueKind === "date") return formatDate(String(value));
|
|
||||||
if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено";
|
|
||||||
if (value === "blocked") return "Запрещено";
|
|
||||||
if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных";
|
|
||||||
if (typeof value === "object") return JSON.stringify(value);
|
|
||||||
return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(value: string | null | undefined) {
|
|
||||||
if (!value) return "Нет данных";
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const __deviceInventoryTestables = {
|
|
||||||
formatFieldValue,
|
|
||||||
readPath,
|
|
||||||
} as const;
|
|
||||||
@@ -1,771 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|
||||||
import {
|
|
||||||
AdminNavigationPanel,
|
|
||||||
AppHeader,
|
|
||||||
ApplicationPanel,
|
|
||||||
ApplicationShell,
|
|
||||||
Button,
|
|
||||||
GlassSurface,
|
|
||||||
HeaderProfile,
|
|
||||||
HeaderWorkspace,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Select,
|
|
||||||
SettingsCard,
|
|
||||||
StatusBadge,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
UserProfileMenu,
|
|
||||||
Window,
|
|
||||||
WindowFooterActions,
|
|
||||||
useApplicationWorkspace,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
import {
|
|
||||||
claimDevice,
|
|
||||||
ensureCollection,
|
|
||||||
ensureEnrollmentIntent,
|
|
||||||
ensureOwnerScope,
|
|
||||||
ensureProject,
|
|
||||||
loadProjects,
|
|
||||||
loadSession,
|
|
||||||
loadWorkspace,
|
|
||||||
} from "./api";
|
|
||||||
import type {
|
|
||||||
DeviceManagerSession,
|
|
||||||
EnrollmentView,
|
|
||||||
OwnerScopeClaim,
|
|
||||||
ProjectSummary,
|
|
||||||
ProjectWorkspace,
|
|
||||||
} from "./types";
|
|
||||||
import {
|
|
||||||
DeviceControlView,
|
|
||||||
type ControlViewId,
|
|
||||||
} from "./DeviceControlViews";
|
|
||||||
import { DeviceInventoryView } from "./DeviceInventoryView";
|
|
||||||
|
|
||||||
type ViewId =
|
|
||||||
| "overview"
|
|
||||||
| "inventory"
|
|
||||||
| "discovery"
|
|
||||||
| "collections"
|
|
||||||
| ControlViewId;
|
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{ id: "overview", label: "Обзор", icon: "grid", capability: null },
|
|
||||||
{ id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" },
|
|
||||||
{ id: "discovery", label: "Подключение", icon: "network", capability: "inventory.read" },
|
|
||||||
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
|
|
||||||
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
|
|
||||||
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
|
|
||||||
{ id: "sessions", label: "Сессии", icon: "activity", capability: "telemetry.observe" },
|
|
||||||
{ id: "bindings", label: "Bindings", icon: "external", capability: "binding.manage" },
|
|
||||||
{ id: "commands", label: "Команды", icon: "target", capability: "command.plan" },
|
|
||||||
{ id: "audit", label: "Аудит", icon: "clipboard", capability: "audit.read" },
|
|
||||||
{ id: "access", label: "Доступ", icon: "users", capability: "access.manage" },
|
|
||||||
{ id: "settings", label: "Настройки", icon: "settings", capability: "configuration.read" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function DeviceManagerApp() {
|
|
||||||
const shell = useApplicationWorkspace<ViewId>({ navigationOpen: true });
|
|
||||||
const [session, setSession] = useState<DeviceManagerSession | null>(null);
|
|
||||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
|
||||||
const [workspace, setWorkspace] = useState<ProjectWorkspace | null>(null);
|
|
||||||
const [activeOwnerRef, setActiveOwnerRef] = useState("");
|
|
||||||
const [activeProjectRef, setActiveProjectRef] = useState("");
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
|
|
||||||
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
|
|
||||||
const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false);
|
|
||||||
const [claimEnrollment, setClaimEnrollment] = useState<EnrollmentView | null>(null);
|
|
||||||
|
|
||||||
const refreshProjects = async () => {
|
|
||||||
const next = await loadProjects();
|
|
||||||
setProjects(next);
|
|
||||||
setActiveProjectRef((current) =>
|
|
||||||
next.some((project) => project.projectRef === current)
|
|
||||||
? current
|
|
||||||
: next[0]?.projectRef ?? ""
|
|
||||||
);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
Promise.all([loadSession(), loadProjects()])
|
|
||||||
.then(([nextSession, nextProjects]) => {
|
|
||||||
if (!active) return;
|
|
||||||
setSession(nextSession);
|
|
||||||
setProjects(nextProjects);
|
|
||||||
const ownerRef = nextProjects[0]?.ownerScope.ownerRef
|
|
||||||
|| nextSession.actor.ownerScopes[0]?.ownerRef
|
|
||||||
|| "";
|
|
||||||
setActiveOwnerRef(ownerRef);
|
|
||||||
setActiveProjectRef(nextProjects[0]?.projectRef ?? "");
|
|
||||||
})
|
|
||||||
.catch((reason) => active && setError(errorText(reason)))
|
|
||||||
.finally(() => active && setLoading(false));
|
|
||||||
return () => { active = false; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeProjectRef) {
|
|
||||||
setWorkspace(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let active = true;
|
|
||||||
loadWorkspace(activeProjectRef)
|
|
||||||
.then((next) => active && setWorkspace(next))
|
|
||||||
.catch((reason) => active && setError(errorText(reason)));
|
|
||||||
return () => { active = false; };
|
|
||||||
}, [activeProjectRef]);
|
|
||||||
|
|
||||||
const ownerScopes = useMemo(
|
|
||||||
() => mergeOwnerScopes(session?.actor.ownerScopes ?? [], projects),
|
|
||||||
[projects, session],
|
|
||||||
);
|
|
||||||
const visibleProjects = useMemo(
|
|
||||||
() => projects.filter((project) => !activeOwnerRef || project.ownerScope.ownerRef === activeOwnerRef),
|
|
||||||
[activeOwnerRef, projects],
|
|
||||||
);
|
|
||||||
const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
|
|
||||||
const creatableOwnerScopes = session?.actor.ownerScopes ?? [];
|
|
||||||
const capabilities = new Set(activeProject?.access.capabilities ?? []);
|
|
||||||
const canCreateProject = creatableOwnerScopes.length > 0;
|
|
||||||
const canManageCollections = capabilities.has("collection.manage");
|
|
||||||
const canEnroll = capabilities.has("device.enroll");
|
|
||||||
const canClaim = capabilities.has("device.claim");
|
|
||||||
const visibleNavigationItems = navigationItems.filter(
|
|
||||||
(item) => item.capability === null || capabilities.has(item.capability),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
|
|
||||||
}, [activeOwnerRef, ownerScopes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
activeProjectRef
|
|
||||||
&& !visibleProjects.some((project) => project.projectRef === activeProjectRef)
|
|
||||||
) {
|
|
||||||
setActiveProjectRef(visibleProjects[0]?.projectRef ?? "");
|
|
||||||
setWorkspace(null);
|
|
||||||
}
|
|
||||||
}, [activeOwnerRef, activeProjectRef, visibleProjects]);
|
|
||||||
|
|
||||||
const openProject = (projectRef: string, view: ViewId = "overview") => {
|
|
||||||
setActiveProjectRef(projectRef);
|
|
||||||
shell.openView(view);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshWorkspace = async () => {
|
|
||||||
if (!activeProjectRef) return;
|
|
||||||
const next = await loadWorkspace(activeProjectRef);
|
|
||||||
setWorkspace(next);
|
|
||||||
await refreshProjects();
|
|
||||||
};
|
|
||||||
|
|
||||||
const pollWorkspace = async () => {
|
|
||||||
if (!activeProjectRef) return;
|
|
||||||
const next = await loadWorkspace(activeProjectRef);
|
|
||||||
setWorkspace(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading || !session) {
|
|
||||||
return <div className="device-manager-boot">Подключаем Device Core…</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeView = shell.activeView ?? "overview";
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ApplicationShell
|
|
||||||
header={
|
|
||||||
<AppHeader
|
|
||||||
brand={<img className="device-manager-brand" src="/nodedc-logo.svg" alt="NODE.DC" />}
|
|
||||||
brandHref="/"
|
|
||||||
center={
|
|
||||||
<button
|
|
||||||
className="device-manager-workspace-trigger"
|
|
||||||
type="button"
|
|
||||||
aria-label="Открыть навигацию Device Core"
|
|
||||||
onClick={shell.toggleNavigation}
|
|
||||||
>
|
|
||||||
<HeaderWorkspace kind="mark" label="Device Core" imageUrl="/nodedc-mark.svg" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
right={
|
|
||||||
<HeaderProfile>
|
|
||||||
<UserProfileMenu
|
|
||||||
displayName={session.user.displayName}
|
|
||||||
subtitle={session.user.email}
|
|
||||||
avatarUrl={session.user.avatarUrl ?? undefined}
|
|
||||||
actions={[
|
|
||||||
{ id: "profile", label: "Профиль", icon: "profile", href: session.profileUrl },
|
|
||||||
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</HeaderProfile>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
stage={
|
|
||||||
<DeviceStage
|
|
||||||
projects={visibleProjects}
|
|
||||||
onOpenProject={openProject}
|
|
||||||
error={error}
|
|
||||||
onDismissError={() => setError(null)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
navigationOpen={shell.navigationOpen}
|
|
||||||
navigation={
|
|
||||||
<AdminNavigationPanel
|
|
||||||
eyebrow="DEVICE CORE"
|
|
||||||
title="Проекты"
|
|
||||||
headerActions={
|
|
||||||
<IconButton
|
|
||||||
label="Новый проект"
|
|
||||||
disabled={!canCreateProject}
|
|
||||||
onClick={() => setProjectDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<Icon name="plus" size={16} />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
contextSlot={
|
|
||||||
ownerScopes.length ? (
|
|
||||||
<div className="device-manager-navigation-context">
|
|
||||||
<Select
|
|
||||||
value={activeOwnerRef}
|
|
||||||
label="Контур владельца"
|
|
||||||
options={ownerScopes.map((scope) => ({
|
|
||||||
value: scope.ownerRef,
|
|
||||||
label: scope.displayName,
|
|
||||||
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
|
|
||||||
}))}
|
|
||||||
onChange={setActiveOwnerRef}
|
|
||||||
searchable={ownerScopes.length > 6}
|
|
||||||
/>
|
|
||||||
{visibleProjects.length ? (
|
|
||||||
<Select
|
|
||||||
value={activeProjectRef}
|
|
||||||
label="Проект"
|
|
||||||
options={visibleProjects.map((project) => ({
|
|
||||||
value: project.projectRef,
|
|
||||||
label: project.name,
|
|
||||||
description: project.access.projectRole || "read",
|
|
||||||
}))}
|
|
||||||
onChange={(projectRef) => openProject(projectRef)}
|
|
||||||
searchable={visibleProjects.length > 6}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
items={activeProject ? visibleNavigationItems.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
label: item.label,
|
|
||||||
icon: <Icon name={item.icon} />,
|
|
||||||
})) : []}
|
|
||||||
activeId={activeProject ? activeView : undefined}
|
|
||||||
footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>}
|
|
||||||
onClose={shell.closeNavigation}
|
|
||||||
onItemChange={(id) => shell.openView(id as ViewId)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
contentOpen={shell.contentOpen}
|
|
||||||
contentExpanded={shell.contentExpanded}
|
|
||||||
content={
|
|
||||||
activeProject ? (
|
|
||||||
<ApplicationPanel
|
|
||||||
eyebrow={activeProject.ownerScope.displayName}
|
|
||||||
title={viewTitle(activeView)}
|
|
||||||
description={activeProject.name}
|
|
||||||
expanded={shell.contentExpanded}
|
|
||||||
onExpandedChange={shell.setContentExpanded}
|
|
||||||
onClose={shell.closeView}
|
|
||||||
utilityActions={[{
|
|
||||||
label: "Обновить данные",
|
|
||||||
icon: "refresh",
|
|
||||||
onClick: () => refreshWorkspace().catch((reason) => setError(errorText(reason))),
|
|
||||||
}]}
|
|
||||||
>
|
|
||||||
<ProjectView
|
|
||||||
view={activeView}
|
|
||||||
workspace={workspace}
|
|
||||||
canManageCollections={canManageCollections}
|
|
||||||
canEnroll={canEnroll}
|
|
||||||
canClaim={canClaim}
|
|
||||||
session={session}
|
|
||||||
onRefresh={refreshWorkspace}
|
|
||||||
onPoll={pollWorkspace}
|
|
||||||
onError={(reason) => setError(errorText(reason))}
|
|
||||||
onCreateCollection={() => setCollectionDialogOpen(true)}
|
|
||||||
onCreateEnrollment={() => setEnrollmentDialogOpen(true)}
|
|
||||||
onClaim={setClaimEnrollment}
|
|
||||||
/>
|
|
||||||
</ApplicationPanel>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProjectDialog
|
|
||||||
open={projectDialogOpen}
|
|
||||||
ownerScopes={creatableOwnerScopes}
|
|
||||||
initialOwnerRef={activeOwnerRef}
|
|
||||||
onClose={() => setProjectDialogOpen(false)}
|
|
||||||
onCreated={async () => {
|
|
||||||
setProjectDialogOpen(false);
|
|
||||||
await refreshProjects();
|
|
||||||
}}
|
|
||||||
onError={(reason) => setError(errorText(reason))}
|
|
||||||
/>
|
|
||||||
<CollectionDialog
|
|
||||||
open={collectionDialogOpen}
|
|
||||||
project={activeProject}
|
|
||||||
onClose={() => setCollectionDialogOpen(false)}
|
|
||||||
onCreated={async () => {
|
|
||||||
setCollectionDialogOpen(false);
|
|
||||||
await refreshWorkspace();
|
|
||||||
}}
|
|
||||||
onError={(reason) => setError(errorText(reason))}
|
|
||||||
/>
|
|
||||||
<ClaimDialog
|
|
||||||
enrollment={claimEnrollment}
|
|
||||||
project={activeProject}
|
|
||||||
onClose={() => setClaimEnrollment(null)}
|
|
||||||
onClaimed={async () => {
|
|
||||||
setClaimEnrollment(null);
|
|
||||||
await refreshWorkspace();
|
|
||||||
}}
|
|
||||||
onError={(reason) => setError(errorText(reason))}
|
|
||||||
/>
|
|
||||||
<EnrollmentDialog
|
|
||||||
open={enrollmentDialogOpen}
|
|
||||||
workspace={workspace}
|
|
||||||
onClose={() => setEnrollmentDialogOpen(false)}
|
|
||||||
onCreated={async () => {
|
|
||||||
setEnrollmentDialogOpen(false);
|
|
||||||
await refreshWorkspace();
|
|
||||||
}}
|
|
||||||
onError={(reason) => setError(errorText(reason))}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeviceStage({
|
|
||||||
projects,
|
|
||||||
onOpenProject,
|
|
||||||
error,
|
|
||||||
onDismissError,
|
|
||||||
}: {
|
|
||||||
projects: ProjectSummary[];
|
|
||||||
onOpenProject: (projectRef: string, view?: ViewId) => void;
|
|
||||||
error: string | null;
|
|
||||||
onDismissError: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="device-manager-stage">
|
|
||||||
{error ? (
|
|
||||||
<div className="device-manager-alert" role="alert">
|
|
||||||
<Icon name="alert" />
|
|
||||||
<span>{error}</span>
|
|
||||||
<Button variant="ghost" size="compact" onClick={onDismissError}>Закрыть</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<GlassSurface className="device-manager-home" padding="lg" tone="soft">
|
|
||||||
<section className="device-manager-hero">
|
|
||||||
<span>NODEDC / DEVICE CORE</span>
|
|
||||||
<h1>Device Core</h1>
|
|
||||||
<p>Единый контур подключения, учёта и управления устройствами.</p>
|
|
||||||
</section>
|
|
||||||
<section className="device-manager-projects">
|
|
||||||
<div className="device-manager-section-title">
|
|
||||||
<div><span>DEVICE PROJECTS</span><h2>Рабочие проекты</h2></div>
|
|
||||||
<small>Доступ определяется Hub и project grants</small>
|
|
||||||
</div>
|
|
||||||
{projects.length ? (
|
|
||||||
<div className="device-manager-project-grid">
|
|
||||||
{projects.map((project) => (
|
|
||||||
<button key={project.projectRef} type="button" className="device-project-card" onClick={() => onOpenProject(project.projectRef)}>
|
|
||||||
<span className="device-project-card__icon"><Icon name="apps" size={20} /></span>
|
|
||||||
<span className="device-project-card__body">
|
|
||||||
<small>{project.ownerScope.displayName}</small>
|
|
||||||
<strong>{project.name}</strong>
|
|
||||||
<span>{project.description || "Универсальный проект устройств"}</span>
|
|
||||||
</span>
|
|
||||||
<span className="device-project-card__stats">
|
|
||||||
<b>{project.counts.devices}</b> устройств
|
|
||||||
<b>{project.counts.collections}</b> коллекций
|
|
||||||
</span>
|
|
||||||
<StatusBadge tone={project.lifecycleState === "active" ? "success" : "warning"}>
|
|
||||||
{project.access.projectRole || "read"}
|
|
||||||
</StatusBadge>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<GlassSurface className="device-manager-empty" padding="lg" tone="soft">
|
|
||||||
<Icon name="inbox" size={24} />
|
|
||||||
<h3>В этом контуре пока нет проектов</h3>
|
|
||||||
<p>Создание доступно только в owner scope, подтверждённом Hub.</p>
|
|
||||||
</GlassSurface>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</GlassSurface>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) {
|
|
||||||
return (
|
|
||||||
<GlassSurface className="device-manager-metric" padding="md" tone="soft">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
<StatusBadge tone={tone}>{detail}</StatusBadge>
|
|
||||||
</GlassSurface>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onPoll, onError, onCreateCollection, onCreateEnrollment, onClaim }: {
|
|
||||||
view: ViewId;
|
|
||||||
workspace: ProjectWorkspace | null;
|
|
||||||
canManageCollections: boolean;
|
|
||||||
canEnroll: boolean;
|
|
||||||
canClaim: boolean;
|
|
||||||
session: DeviceManagerSession;
|
|
||||||
onRefresh: () => Promise<void>;
|
|
||||||
onPoll: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
onCreateCollection: () => void;
|
|
||||||
onCreateEnrollment: () => void;
|
|
||||||
onClaim: (enrollment: EnrollmentView) => void;
|
|
||||||
}) {
|
|
||||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
|
||||||
if (["catalog", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
|
|
||||||
return <DeviceControlView
|
|
||||||
view={view as ControlViewId}
|
|
||||||
workspace={workspace}
|
|
||||||
session={session}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
onError={onError}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
if (view === "inventory") return (
|
|
||||||
<DeviceInventoryView
|
|
||||||
workspace={workspace}
|
|
||||||
canEnroll={canEnroll}
|
|
||||||
onCreateEnrollment={onCreateEnrollment}
|
|
||||||
onPoll={onPoll}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (view === "discovery") return (
|
|
||||||
<div className="device-manager-stack">
|
|
||||||
<div className="device-manager-panel-toolbar">
|
|
||||||
<p>Заранее разрешите конкретный идентификатор на активном маршруте. Core сохранит только HMAC и маску.</p>
|
|
||||||
<Button
|
|
||||||
icon={<Icon name="plus" />}
|
|
||||||
variant="primary"
|
|
||||||
disabled={!canEnroll || !workspace.routes.some((route) => route.lifecycleState === "active")}
|
|
||||||
onClick={onCreateEnrollment}
|
|
||||||
>
|
|
||||||
Подключить устройство
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{workspace.enrollments.map((enrollment) => (
|
|
||||||
<SettingsCard
|
|
||||||
key={enrollment.enrollmentIntentRef}
|
|
||||||
eyebrow={enrollment.lifecycleState}
|
|
||||||
title={enrollment.displayName}
|
|
||||||
description={`${enrollment.modelProfileRef} · ${enrollment.expectedIdentifier.masked}`}
|
|
||||||
actions={enrollment.lifecycleState === "observed" && enrollment.observedDiscoveryRef ? (
|
|
||||||
<Button size="compact" variant="primary" disabled={!canClaim} onClick={() => onClaim(enrollment)}>
|
|
||||||
Принять устройство
|
|
||||||
</Button>
|
|
||||||
) : <StatusBadge>{enrollment.lifecycleState}</StatusBadge>}
|
|
||||||
>
|
|
||||||
<p className="device-manager-card-copy">Enrollment и quarantine evidence остаются связанными; raw identifier в интерфейс не передаётся.</p>
|
|
||||||
</SettingsCard>
|
|
||||||
))}
|
|
||||||
{!workspace.enrollments.length ? <div className="device-manager-panel-empty">Нет ожидающих подключений.</div> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (view === "collections") return (
|
|
||||||
<div className="device-manager-stack">
|
|
||||||
<div className="device-manager-panel-toolbar">
|
|
||||||
<p>Коллекции группируют устройства для последующих bindings и задач.</p>
|
|
||||||
<Button icon={<Icon name="plus" />} disabled={!canManageCollections} onClick={onCreateCollection}>Новая коллекция</Button>
|
|
||||||
</div>
|
|
||||||
<EntityList
|
|
||||||
empty="Коллекций пока нет."
|
|
||||||
items={workspace.collections.map((collection) => ({
|
|
||||||
id: collection.collectionRef,
|
|
||||||
title: collection.name,
|
|
||||||
subtitle: collection.description || collection.collectionKey,
|
|
||||||
status: `${collection.memberCount} устройств`,
|
|
||||||
tone: "neutral",
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="device-manager-overview-grid">
|
|
||||||
<Metric label="Устройства" value={workspace.devices.length} detail="inventory" />
|
|
||||||
<Metric label="Quarantine" value={workspace.discoveries.filter((item) => item.lifecycleState === "quarantine").length} detail="safe projection" />
|
|
||||||
<Metric label="Коллекции" value={workspace.collections.length} detail="bindings ready" />
|
|
||||||
<SettingsCard title="Права проекта" description={workspace.project.access.projectRole || "read"}>
|
|
||||||
<div className="device-manager-capabilities">
|
|
||||||
{workspace.project.access.capabilities.map((capability) => <span key={capability}>{capability}</span>)}
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) {
|
|
||||||
if (!items.length) return <div className="device-manager-panel-empty">{empty}</div>;
|
|
||||||
return <div className="device-manager-entity-list">{items.map((item) => (
|
|
||||||
<GlassSurface key={item.id} className="device-manager-entity" padding="md" tone="soft">
|
|
||||||
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
|
|
||||||
<span className="device-manager-entity__body"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
|
|
||||||
<StatusBadge tone={item.tone}>{item.status}</StatusBadge>
|
|
||||||
</GlassSurface>
|
|
||||||
))}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectDialog({ open, ownerScopes, initialOwnerRef, onClose, onCreated, onError }: {
|
|
||||||
open: boolean;
|
|
||||||
ownerScopes: OwnerScopeClaim[];
|
|
||||||
initialOwnerRef: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onCreated: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [ownerRef, setOwnerRef] = useState(initialOwnerRef);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setOwnerRef(ownerScopes.some((scope) => scope.ownerRef === initialOwnerRef)
|
|
||||||
? initialOwnerRef
|
|
||||||
: ownerScopes[0]?.ownerRef ?? "");
|
|
||||||
}, [initialOwnerRef, open, ownerScopes]);
|
|
||||||
const ownerScope = ownerScopes.find((scope) => scope.ownerRef === ownerRef) ?? null;
|
|
||||||
const submit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!ownerScope) return;
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await ensureOwnerScope(ownerScope);
|
|
||||||
await ensureProject({
|
|
||||||
scopeKind: ownerScope.scopeKind,
|
|
||||||
ownerRef: ownerScope.ownerRef,
|
|
||||||
projectKey: key,
|
|
||||||
name,
|
|
||||||
description: description.trim() || null,
|
|
||||||
});
|
|
||||||
setName(""); setKey(""); setDescription("");
|
|
||||||
await onCreated();
|
|
||||||
} catch (reason) { onError(reason); } finally { setPending(false); }
|
|
||||||
};
|
|
||||||
return <Window open={open} title="Новый Device Project" subtitle={ownerScope?.displayName || "Owner scope недоступен"} onClose={onClose} footer={
|
|
||||||
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-project-form" variant="primary" disabled={pending || !ownerScope}>{pending ? "Создаём…" : "Создать"}</Button></WindowFooterActions>
|
|
||||||
}>
|
|
||||||
<form id="device-project-form" className="device-manager-form" onSubmit={submit}>
|
|
||||||
<Select
|
|
||||||
label="Контур проекта"
|
|
||||||
value={ownerRef}
|
|
||||||
options={ownerScopes.map((scope) => ({
|
|
||||||
value: scope.ownerRef,
|
|
||||||
label: scope.displayName,
|
|
||||||
description: scope.scopeKind === "company" ? "Коммерческий контур" : "Личный контур",
|
|
||||||
}))}
|
|
||||||
onChange={setOwnerRef}
|
|
||||||
/>
|
|
||||||
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
|
||||||
<TextField label="Ключ проекта" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." />
|
|
||||||
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
|
|
||||||
</form>
|
|
||||||
</Window>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CollectionDialog({ open, project, onClose, onCreated, onError }: {
|
|
||||||
open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise<void>; onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const submit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault(); if (!project) return; setPending(true);
|
|
||||||
try {
|
|
||||||
await ensureCollection({ projectRef: project.projectRef, collectionKey: key, name, description: description.trim() || null });
|
|
||||||
setName(""); setKey(""); setDescription(""); await onCreated();
|
|
||||||
} catch (reason) { onError(reason); } finally { setPending(false); }
|
|
||||||
};
|
|
||||||
return <Window open={open} title="Новая коллекция" subtitle={project?.name} onClose={onClose} footer={
|
|
||||||
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-collection-form" variant="primary" disabled={pending || !project}>{pending ? "Сохраняем…" : "Создать"}</Button></WindowFooterActions>
|
|
||||||
}>
|
|
||||||
<form id="device-collection-form" className="device-manager-form" onSubmit={submit}>
|
|
||||||
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
|
||||||
<TextField label="Ключ коллекции" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
|
|
||||||
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
|
|
||||||
</form>
|
|
||||||
</Window>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
|
|
||||||
enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise<void>; onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
useEffect(() => { if (enrollment) setName(enrollment.displayName); }, [enrollment]);
|
|
||||||
const submit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!project || !enrollment?.observedDiscoveryRef) return;
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await claimDevice({
|
|
||||||
projectRef: project.projectRef,
|
|
||||||
enrollmentIntentRef: enrollment.enrollmentIntentRef,
|
|
||||||
discoveryRef: enrollment.observedDiscoveryRef,
|
|
||||||
deviceKey: key,
|
|
||||||
displayName: name,
|
|
||||||
});
|
|
||||||
setName(""); setKey(""); await onClaimed();
|
|
||||||
} catch (reason) { onError(reason); } finally { setPending(false); }
|
|
||||||
};
|
|
||||||
return <Window open={Boolean(enrollment)} title="Принять устройство" subtitle={enrollment?.expectedIdentifier.masked} onClose={onClose} footer={
|
|
||||||
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-claim-form" variant="primary" disabled={pending || !enrollment}>{pending ? "Проверяем…" : "Принять"}</Button></WindowFooterActions>
|
|
||||||
}>
|
|
||||||
<form id="device-claim-form" className="device-manager-form" onSubmit={submit}>
|
|
||||||
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
|
||||||
<TextField label="Ключ устройства" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
|
|
||||||
<p className="device-manager-card-copy">Claim использует только ссылки на enrollment и discovery. Исходный идентификатор не запрашивается повторно.</p>
|
|
||||||
</form>
|
|
||||||
</Window>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: {
|
|
||||||
open: boolean;
|
|
||||||
workspace: ProjectWorkspace | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onCreated: () => Promise<void>;
|
|
||||||
onError: (reason: unknown) => void;
|
|
||||||
}) {
|
|
||||||
const activeRoutes = useMemo(
|
|
||||||
() => workspace?.routes.filter((route) => route.lifecycleState === "active") ?? [],
|
|
||||||
[workspace],
|
|
||||||
);
|
|
||||||
const [routeRef, setRouteRef] = useState("");
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [imei, setImei] = useState("");
|
|
||||||
const [expiresAt, setExpiresAt] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeRoutes.some((route) => route.routeRef === routeRef)) {
|
|
||||||
setRouteRef(activeRoutes[0]?.routeRef ?? "");
|
|
||||||
}
|
|
||||||
}, [activeRoutes, routeRef]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) setImei("");
|
|
||||||
}, [open]);
|
|
||||||
const submit = async (event: FormEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const route = activeRoutes.find((item) => item.routeRef === routeRef);
|
|
||||||
if (!workspace || !route) return;
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await ensureEnrollmentIntent({
|
|
||||||
projectRef: workspace.project.projectRef,
|
|
||||||
enrollmentKey: key,
|
|
||||||
routeRef: route.routeRef,
|
|
||||||
modelProfileRef: route.modelProfileRef,
|
|
||||||
displayName: name,
|
|
||||||
identifier: { kind: "imei", value: imei },
|
|
||||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
|
||||||
});
|
|
||||||
setImei("");
|
|
||||||
setName("");
|
|
||||||
setKey("");
|
|
||||||
setExpiresAt("");
|
|
||||||
await onCreated();
|
|
||||||
} catch (reason) {
|
|
||||||
onError(reason);
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return <Window open={open} title="Подключить устройство" subtitle={workspace?.project.name} onClose={onClose} footer={
|
|
||||||
<WindowFooterActions>
|
|
||||||
<Button variant="ghost" onClick={onClose}>Отмена</Button>
|
|
||||||
<Button type="submit" form="device-enrollment-form" variant="primary" disabled={pending || !workspace || !routeRef}>
|
|
||||||
{pending ? "Защищаем идентификатор…" : "Создать enrollment"}
|
|
||||||
</Button>
|
|
||||||
</WindowFooterActions>
|
|
||||||
}>
|
|
||||||
<form id="device-enrollment-form" className="device-manager-form" onSubmit={submit}>
|
|
||||||
<Select
|
|
||||||
label="Активный маршрут"
|
|
||||||
value={routeRef}
|
|
||||||
onChange={setRouteRef}
|
|
||||||
options={activeRoutes.map((route) => ({
|
|
||||||
value: route.routeRef,
|
|
||||||
label: route.displayName,
|
|
||||||
description: `${route.protocol} · ${route.modelProfileRef}`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
|
|
||||||
<TextField label="Ключ enrollment" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
|
|
||||||
<TextField label="IMEI" value={imei} onChange={(event) => setImei(event.target.value.replace(/\D/g, "").slice(0, 15))} required pattern="[0-9]{15}" inputMode="numeric" autoComplete="off" />
|
|
||||||
<TextField label="Истекает" type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} description="Опционально. Время будет сохранено в UTC." />
|
|
||||||
<p className="device-manager-card-copy">IMEI передаётся один раз по HTTPS в Device Control Core. В базе, audit и ответе останутся только HMAC и маска.</p>
|
|
||||||
</form>
|
|
||||||
</Window>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[]) {
|
|
||||||
const scopes = new Map(claims.map((scope) => [scope.ownerRef, scope]));
|
|
||||||
for (const project of projects) {
|
|
||||||
if (!scopes.has(project.ownerScope.ownerRef)) {
|
|
||||||
scopes.set(project.ownerScope.ownerRef, {
|
|
||||||
scopeKind: project.ownerScope.scopeKind,
|
|
||||||
ownerRef: project.ownerScope.ownerRef,
|
|
||||||
displayName: project.ownerScope.displayName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...scopes.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewTitle(view: ViewId) {
|
|
||||||
return ({
|
|
||||||
overview: "Обзор проекта",
|
|
||||||
inventory: "Устройства",
|
|
||||||
discovery: "Подключение",
|
|
||||||
collections: "Коллекции",
|
|
||||||
catalog: "Модели и адаптеры",
|
|
||||||
infrastructure: "Edges и маршруты",
|
|
||||||
sessions: "Gateway sessions",
|
|
||||||
bindings: "Data bindings",
|
|
||||||
commands: "Command ledger",
|
|
||||||
audit: "Immutable audit",
|
|
||||||
access: "Доступ к проекту",
|
|
||||||
settings: "Настройки и конфигурации",
|
|
||||||
})[view];
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorText(reason: unknown) {
|
|
||||||
const value = reason instanceof Error ? reason.message : String(reason || "device_manager_error");
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
device_project_capability_denied: "Недостаточно прав в выбранном проекте.",
|
|
||||||
device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.",
|
|
||||||
device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.",
|
|
||||||
};
|
|
||||||
return labels[value] || value;
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import type {
|
|
||||||
AdapterPackageView,
|
|
||||||
AdapterVersionView,
|
|
||||||
BindingView,
|
|
||||||
ConfigurationRevisionView,
|
|
||||||
EdgeView,
|
|
||||||
ModelProfileView,
|
|
||||||
ProjectGrantView,
|
|
||||||
DeviceManagerSession,
|
|
||||||
ProjectSummary,
|
|
||||||
ProjectWorkspace,
|
|
||||||
RouteView,
|
|
||||||
ScopeKind,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
export async function loadSession(): Promise<DeviceManagerSession> {
|
|
||||||
return requestJson<{ ok: true; session: DeviceManagerSession }>(
|
|
||||||
"/api/device-manager/session",
|
|
||||||
).then((value) => value.session);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadProjects(): Promise<ProjectSummary[]> {
|
|
||||||
return requestJson<{ ok: true; projects: ProjectSummary[] }>(
|
|
||||||
"/api/device-manager/projects",
|
|
||||||
).then((value) => value.projects);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadWorkspace(projectRef: string): Promise<ProjectWorkspace> {
|
|
||||||
return requestJson<{ ok: true; workspace: ProjectWorkspace }>(
|
|
||||||
`/api/device-manager/projects/${encodeURIComponent(projectRef)}/workspace`,
|
|
||||||
).then((value) => value.workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureOwnerScope(input: {
|
|
||||||
scopeKind: ScopeKind;
|
|
||||||
ownerRef: string;
|
|
||||||
displayName: string;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/owner-scopes:ensure", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureProject(input: {
|
|
||||||
scopeKind: ScopeKind;
|
|
||||||
ownerRef: string;
|
|
||||||
projectKey: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/projects:ensure", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureCollection(input: {
|
|
||||||
projectRef: string;
|
|
||||||
collectionKey: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/collections:ensure", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function claimDevice(input: {
|
|
||||||
projectRef: string;
|
|
||||||
enrollmentIntentRef: string;
|
|
||||||
discoveryRef: string;
|
|
||||||
deviceKey: string;
|
|
||||||
displayName: string;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/devices:claim", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureEnrollmentIntent(input: {
|
|
||||||
projectRef: string;
|
|
||||||
enrollmentKey: string;
|
|
||||||
routeRef: string;
|
|
||||||
modelProfileRef: string;
|
|
||||||
displayName: string;
|
|
||||||
identifier: { kind: "imei"; value: string };
|
|
||||||
expiresAt: string | null;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/enrollment-intents:ensure", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertProjectGrant(input: {
|
|
||||||
projectRef: string;
|
|
||||||
principalKind: "user" | "group";
|
|
||||||
principalRef: string;
|
|
||||||
projectRole: string;
|
|
||||||
capabilityAllow: string[];
|
|
||||||
capabilityDeny: string[];
|
|
||||||
lifecycleState: "active" | "revoked";
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; grant: ProjectGrantView }>(
|
|
||||||
"/api/device-manager/project-grants:upsert",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureAdapterPackage(input: {
|
|
||||||
packageKey: string;
|
|
||||||
displayName: string;
|
|
||||||
publisherRef: string;
|
|
||||||
lifecycleState: "active" | "retired";
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; adapterPackage: AdapterPackageView }>(
|
|
||||||
"/api/device-manager/adapter-packages:ensure",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerAdapterVersion(input: {
|
|
||||||
adapterPackageRef: string;
|
|
||||||
version: string;
|
|
||||||
runtimePackageRef: string;
|
|
||||||
contentDigest: string;
|
|
||||||
contractVersion: string;
|
|
||||||
capabilities: string[];
|
|
||||||
lifecycleState: "draft" | "active" | "retired";
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; adapterVersion: AdapterVersionView }>(
|
|
||||||
"/api/device-manager/adapter-versions:register",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function registerModelProfile(input: {
|
|
||||||
adapterVersionRef: string;
|
|
||||||
profileRef: string;
|
|
||||||
schemaVersion: string;
|
|
||||||
vendor: string;
|
|
||||||
model: string;
|
|
||||||
deviceType: string;
|
|
||||||
protocol: string;
|
|
||||||
schemaArtifactRef: string;
|
|
||||||
profileDigest: string;
|
|
||||||
capabilities: string[];
|
|
||||||
lifecycleState: "draft" | "active" | "retired";
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; modelProfile: ModelProfileView }>(
|
|
||||||
"/api/device-manager/model-profiles:register",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureEdge(input: {
|
|
||||||
edgeKey: string;
|
|
||||||
displayName: string;
|
|
||||||
deploymentRef: string | null;
|
|
||||||
lifecycleState: "provisioning" | "active" | "suspended" | "retired";
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; edge: EdgeView }>(
|
|
||||||
"/api/device-manager/edges:ensure",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureRoute(input: {
|
|
||||||
projectRef: string;
|
|
||||||
routeKey: string;
|
|
||||||
displayName: string;
|
|
||||||
edgeRef: string;
|
|
||||||
modelProfileRef: string;
|
|
||||||
listenerRef: string;
|
|
||||||
protocol: string;
|
|
||||||
direction: "telemetry" | "bidirectional";
|
|
||||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; route: RouteView }>(
|
|
||||||
"/api/device-manager/routes:ensure",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureDeviceBinding(input: {
|
|
||||||
projectRef: string;
|
|
||||||
bindingKey: string;
|
|
||||||
displayName: string;
|
|
||||||
source: { kind: "device" | "collection"; ref: string };
|
|
||||||
targetKind: string;
|
|
||||||
targetRef: string;
|
|
||||||
capabilities: string[];
|
|
||||||
}) {
|
|
||||||
return mutate<{ created: boolean; binding: BindingView }>(
|
|
||||||
"/api/device-manager/device-bindings:ensure",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeDeviceBinding(input: {
|
|
||||||
projectRef: string;
|
|
||||||
bindingRef: string;
|
|
||||||
resolutionCode: string;
|
|
||||||
}) {
|
|
||||||
return mutate<{ revoked: boolean; binding: BindingView }>(
|
|
||||||
"/api/device-manager/device-bindings:revoke",
|
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createConfigurationRevision(input: {
|
|
||||||
projectRef: string;
|
|
||||||
deviceRef: string;
|
|
||||||
configuration: Record<string, unknown>;
|
|
||||||
changeSummary: string | null;
|
|
||||||
}) {
|
|
||||||
return mutate<{
|
|
||||||
created: boolean;
|
|
||||||
configurationRevision: ConfigurationRevisionView;
|
|
||||||
}>("/api/device-manager/device-configuration-revisions:create", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setDesiredConfiguration(input: {
|
|
||||||
projectRef: string;
|
|
||||||
deviceRef: string;
|
|
||||||
configurationRevisionRef: string;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/device-configurations:set-desired", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendServicePing(input: {
|
|
||||||
projectRef: string;
|
|
||||||
deviceRef: string;
|
|
||||||
accessCode: string;
|
|
||||||
expiresInSeconds: number;
|
|
||||||
}) {
|
|
||||||
return mutate("/api/device-manager/commands:service-ping", input);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mutate<T = unknown>(path: string, input: unknown) {
|
|
||||||
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Idempotency-Key": `device-manager-${crypto.randomUUID()}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
||||||
const response = await fetch(path, {
|
|
||||||
credentials: "same-origin",
|
|
||||||
headers: { Accept: "application/json", ...(init?.headers ?? {}) },
|
|
||||||
...init,
|
|
||||||
});
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
if (!response.ok || !body?.ok) {
|
|
||||||
const error = new Error(body?.error || `device_manager_request_failed:${response.status}`);
|
|
||||||
Object.assign(error, { status: response.status });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return body as T;
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
export type DeviceFieldAccess = "read-only" | "managed" | "protected";
|
|
||||||
|
|
||||||
export type DeviceFieldValueKind = "text" | "number" | "boolean" | "date";
|
|
||||||
|
|
||||||
export interface DeviceProfileField {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
path: string;
|
|
||||||
access?: DeviceFieldAccess;
|
|
||||||
valueKind?: DeviceFieldValueKind;
|
|
||||||
unit?: string;
|
|
||||||
description?: string;
|
|
||||||
sensitive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceProfileSection {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
access: DeviceFieldAccess;
|
|
||||||
fields: DeviceProfileField[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceProfileCatalog {
|
|
||||||
profileRef: string;
|
|
||||||
vendor: string;
|
|
||||||
model: string;
|
|
||||||
title: string;
|
|
||||||
sections: DeviceProfileSection[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = (
|
|
||||||
key: string,
|
|
||||||
label: string,
|
|
||||||
path: string,
|
|
||||||
options: Omit<DeviceProfileField, "key" | "label" | "path"> = {},
|
|
||||||
): DeviceProfileField => ({ key, label, path, ...options });
|
|
||||||
|
|
||||||
const managed = (
|
|
||||||
key: string,
|
|
||||||
label: string,
|
|
||||||
path: string,
|
|
||||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
|
||||||
) => field(key, label, path, { ...options, access: "managed" });
|
|
||||||
|
|
||||||
const protectedField = (
|
|
||||||
key: string,
|
|
||||||
label: string,
|
|
||||||
path: string,
|
|
||||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
|
||||||
) => field(key, label, path, { ...options, access: "protected" });
|
|
||||||
|
|
||||||
const serverFields = (slot: number) => [
|
|
||||||
managed(`server-${slot}-host`, `Сервер ${slot}: DNS / IP`, `reported.configuration.monitoring.servers.${slot - 1}.host`),
|
|
||||||
managed(`server-${slot}-port`, `Сервер ${slot}: порт`, `reported.configuration.monitoring.servers.${slot - 1}.port`, { valueKind: "number" }),
|
|
||||||
managed(`server-${slot}-protocol`, `Сервер ${slot}: протокол`, `reported.configuration.monitoring.servers.${slot - 1}.protocol`),
|
|
||||||
managed(`server-${slot}-identity`, `Сервер ${slot}: ID (SN)`, `reported.configuration.monitoring.servers.${slot - 1}.identity`),
|
|
||||||
managed(`server-${slot}-password`, `Сервер ${slot}: пароль`, `reported.configuration.monitoring.servers.${slot - 1}.password`, { sensitive: true }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const managedIndexedFields = (
|
|
||||||
count: number,
|
|
||||||
prefix: string,
|
|
||||||
label: string,
|
|
||||||
path: string,
|
|
||||||
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
|
|
||||||
) => Array.from({ length: count }, (_, index) => managed(
|
|
||||||
`${prefix}-${index + 1}`,
|
|
||||||
`${label} ${index + 1}`,
|
|
||||||
`${path}.${index}`,
|
|
||||||
options,
|
|
||||||
));
|
|
||||||
|
|
||||||
const phoneFields = Array.from({ length: 5 }, (_, index) => [
|
|
||||||
managed(`phone-${index + 1}-number`, `Телефон ${index + 1}: номер`, `reported.configuration.phones.${index}.number`, { sensitive: true }),
|
|
||||||
managed(`phone-${index + 1}-mode`, `Телефон ${index + 1}: режим`, `reported.configuration.phones.${index}.mode`),
|
|
||||||
]).flat();
|
|
||||||
|
|
||||||
const simFields = (slot: number) => [
|
|
||||||
managed(`sim-${slot}-gprs`, `SIM ${slot}: передача данных`, `reported.configuration.simCards.${slot - 1}.gprsEnabled`, { valueKind: "boolean" }),
|
|
||||||
managed(`sim-${slot}-apn`, `SIM ${slot}: APN оператора`, `reported.configuration.simCards.${slot - 1}.apn`),
|
|
||||||
managed(`sim-${slot}-login`, `SIM ${slot}: логин APN`, `reported.configuration.simCards.${slot - 1}.login`, { sensitive: true }),
|
|
||||||
managed(`sim-${slot}-password`, `SIM ${slot}: пароль APN`, `reported.configuration.simCards.${slot - 1}.password`, { sensitive: true }),
|
|
||||||
managed(`sim-${slot}-roaming`, `SIM ${slot}: роуминг`, `reported.configuration.simCards.${slot - 1}.roamingEnabled`, { valueKind: "boolean" }),
|
|
||||||
managed(`sim-${slot}-operator`, `SIM ${slot}: приоритетный оператор`, `reported.configuration.simCards.${slot - 1}.preferredOperatorCode`),
|
|
||||||
managed(`sim-${slot}-pin`, `SIM ${slot}: PIN`, `reported.configuration.simCards.${slot - 1}.pin`, { sensitive: true }),
|
|
||||||
managed(`sim-${slot}-ussd`, `SIM ${slot}: USSD запроса баланса`, `reported.configuration.simCards.${slot - 1}.balanceUssd`, { sensitive: true }),
|
|
||||||
managed(`sim-${slot}-poll`, `SIM ${slot}: период запроса баланса`, `reported.configuration.simCards.${slot - 1}.balancePollHours`, { valueKind: "number", unit: "ч" }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const motionEventFields = ["acceleration", "braking", "cornering", "vertical"].flatMap((event) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
acceleration: "Разгон",
|
|
||||||
braking: "Торможение",
|
|
||||||
cornering: "Угловое ускорение",
|
|
||||||
vertical: "Вертикальное ускорение",
|
|
||||||
};
|
|
||||||
return Array.from({ length: 3 }, (_, level) => [
|
|
||||||
managed(`${event}-${level + 1}-threshold`, `${labels[event]} ${level + 1}: порог`, `reported.configuration.drivingStyle.${event}.${level}.thresholdMg`, { valueKind: "number", unit: "mg" }),
|
|
||||||
managed(`${event}-${level + 1}-duration`, `${labels[event]} ${level + 1}: длительность превышения`, `reported.configuration.drivingStyle.${event}.${level}.durationMs`, { valueKind: "number", unit: "мс" }),
|
|
||||||
managed(`${event}-${level + 1}-reset`, `${labels[event]} ${level + 1}: задержка сброса`, `reported.configuration.drivingStyle.${event}.${level}.resetDelayMs`, { valueKind: "number", unit: "мс" }),
|
|
||||||
]).flat();
|
|
||||||
}).flat();
|
|
||||||
|
|
||||||
const violationFields = ["speed", "rpm"].flatMap((kind) => Array.from({ length: 4 }, (_, level) => [
|
|
||||||
managed(`${kind}-${level + 1}-threshold`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог`, `reported.configuration.drivingStyle.violations.${kind}.${level}.threshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
|
|
||||||
managed(`${kind}-${level + 1}-duration`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: минимальное время`, `reported.configuration.drivingStyle.violations.${kind}.${level}.minimumDurationSeconds`, { valueKind: "number", unit: "с" }),
|
|
||||||
managed(`${kind}-${level + 1}-reset`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог сброса`, `reported.configuration.drivingStyle.violations.${kind}.${level}.resetThreshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
|
|
||||||
]).flat()).flat();
|
|
||||||
|
|
||||||
const modbusRegisterFields = Array.from({ length: 10 }, (_, index) => [
|
|
||||||
managed(`modbus-register-${index + 1}`, `Регистр ${index + 1}: номер`, `reported.configuration.modbus.registers.${index}.number`, { valueKind: "number" }),
|
|
||||||
managed(`modbus-register-${index + 1}-pair`, `Регистр ${index + 1}: читать два регистра`, `reported.configuration.modbus.registers.${index}.readPair`, { valueKind: "boolean" }),
|
|
||||||
]).flat();
|
|
||||||
|
|
||||||
const bleFields = Array.from({ length: 10 }, (_, index) => [
|
|
||||||
managed(`ble-${index + 1}-mac`, `BLE датчик ${index + 1}: MAC`, `reported.configuration.bluetooth.sensors.${index}.mac`),
|
|
||||||
managed(`ble-${index + 1}-integration`, `BLE датчик ${index + 1}: интеграция`, `reported.configuration.bluetooth.sensors.${index}.integrationExpression`),
|
|
||||||
]).flat();
|
|
||||||
|
|
||||||
export const ARUSNAVI_B2_CATALOG: DeviceProfileCatalog = {
|
|
||||||
profileRef: "arusnavi.b2.internal.v1",
|
|
||||||
vendor: "ARUSNAVI",
|
|
||||||
model: "B2",
|
|
||||||
title: "ARUSNAVI B2",
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: "passport",
|
|
||||||
label: "Паспорт",
|
|
||||||
title: "Паспорт и состояние устройства",
|
|
||||||
description: "Реестровая идентичность, профиль модели и текущее состояние канала. Исходный идентификатор показывается только в проекции, разрешённой Device Core.",
|
|
||||||
access: "read-only",
|
|
||||||
fields: [
|
|
||||||
field("display-name", "Название", "device.displayName"),
|
|
||||||
field("device-key", "Ключ устройства", "device.deviceKey"),
|
|
||||||
field("device-ref", "Device Core ref", "device.deviceRef"),
|
|
||||||
field("vendor", "Производитель", "profile.vendor"),
|
|
||||||
field("model", "Модель", "profile.model"),
|
|
||||||
field("device-type", "Тип", "profile.deviceType"),
|
|
||||||
field("profile", "Профиль модели", "device.modelProfileRef"),
|
|
||||||
field("identifier-kind", "Тип идентификатора", "device.identifier.kind"),
|
|
||||||
field("identifier", "Идентификатор", "device.identifier.masked"),
|
|
||||||
field("iccid-1", "ICCID 1", "reported.identity.iccid1"),
|
|
||||||
field("iccid-2", "ICCID 2", "reported.identity.iccid2"),
|
|
||||||
field("lifecycle", "Состояние реестра", "device.lifecycleState"),
|
|
||||||
field("created", "Зарегистрирован", "device.createdAt", { valueKind: "date" }),
|
|
||||||
field("updated", "Обновлён", "device.updatedAt", { valueKind: "date" }),
|
|
||||||
field("reported-at", "Снимок устройства получен", "reported.observedAt", { valueKind: "date" }),
|
|
||||||
managed("asset-model", "Модель актива", "reported.metadata.model"),
|
|
||||||
managed("registration", "Регистрационный номер", "reported.metadata.registrationNumber"),
|
|
||||||
managed("object", "Объект", "reported.metadata.object"),
|
|
||||||
managed("description", "Описание", "reported.metadata.description"),
|
|
||||||
managed("sim-label-1", "Метка SIM 1", "reported.metadata.simLabel1"),
|
|
||||||
managed("sim-label-2", "Метка SIM 2", "reported.metadata.simLabel2"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "live",
|
|
||||||
label: "Онлайн",
|
|
||||||
title: "Живой канал и телеметрия",
|
|
||||||
description: "Значения обновляются из последней gateway-сессии и безопасного снимка телеметрии. Интерфейс опрашивает Device Core, пока открыта карточка.",
|
|
||||||
access: "read-only",
|
|
||||||
fields: [
|
|
||||||
field("session-state", "Состояние соединения", "session.lifecycleState"),
|
|
||||||
field("session-route", "Маршрут", "session.routeName"),
|
|
||||||
field("session-connected", "Подключён", "session.connectedAt", { valueKind: "date" }),
|
|
||||||
field("session-last-seen", "Последний пакет", "session.lastSeenAt", { valueKind: "date" }),
|
|
||||||
field("session-frames", "Принято пакетов", "session.frameCount", { valueKind: "number" }),
|
|
||||||
field("session-bytes", "Принято данных", "session.byteCount", { valueKind: "number", unit: "байт" }),
|
|
||||||
field("latitude", "Широта", "reported.telemetry.navigation.latitude"),
|
|
||||||
field("longitude", "Долгота", "reported.telemetry.navigation.longitude"),
|
|
||||||
field("speed", "Скорость", "reported.telemetry.navigation.speedKph", { valueKind: "number", unit: "км/ч" }),
|
|
||||||
field("altitude", "Высота", "reported.telemetry.navigation.altitudeMeters", { valueKind: "number", unit: "м" }),
|
|
||||||
field("satellites", "Спутники", "reported.telemetry.navigation.satellites", { valueKind: "number" }),
|
|
||||||
field("course", "Курс", "reported.telemetry.navigation.courseDegrees", { valueKind: "number", unit: "°" }),
|
|
||||||
field("hdop", "HDOP", "reported.telemetry.navigation.hdop"),
|
|
||||||
field("gsm-signal", "Уровень GSM", "reported.telemetry.gsm.signal"),
|
|
||||||
field("gsm-operator", "Оператор", "reported.telemetry.gsm.operator"),
|
|
||||||
field("gsm-lac", "LAC", "reported.telemetry.gsm.lac"),
|
|
||||||
field("gsm-cid", "CID", "reported.telemetry.gsm.cid"),
|
|
||||||
field("external-voltage", "Внешнее напряжение", "reported.telemetry.system.externalVoltageMv", { valueKind: "number", unit: "мВ" }),
|
|
||||||
field("internal-voltage", "Внутреннее напряжение", "reported.telemetry.system.internalVoltageMv", { valueKind: "number", unit: "мВ" }),
|
|
||||||
field("errors", "Ошибки и статусы", "reported.telemetry.system.status"),
|
|
||||||
field("inputs", "Входы и выходы", "reported.telemetry.system.io"),
|
|
||||||
field("modules", "Статусы модулей", "reported.telemetry.system.modules"),
|
|
||||||
field("engine-hours", "Моточасы", "reported.telemetry.can.engineHours"),
|
|
||||||
field("odometer", "Пробег", "reported.telemetry.can.odometer"),
|
|
||||||
field("fuel-total", "Полный расход топлива", "reported.telemetry.can.fuelTotal"),
|
|
||||||
field("fuel-level", "Уровень топлива", "reported.telemetry.can.fuelLevel"),
|
|
||||||
field("rpm", "Обороты двигателя", "reported.telemetry.can.rpm"),
|
|
||||||
field("engine-temp", "Температура двигателя", "reported.telemetry.can.engineTemperature"),
|
|
||||||
field("vehicle-speed", "Скорость по CAN", "reported.telemetry.can.vehicleSpeed"),
|
|
||||||
field("axle-pressure", "Давление на оси", "reported.telemetry.can.axlePressure"),
|
|
||||||
field("crash", "Контроллер аварии", "reported.telemetry.can.crashController"),
|
|
||||||
field("instant-fuel", "Моментальный расход", "reported.telemetry.can.instantFuel"),
|
|
||||||
field("adblue", "Уровень AdBlue", "reported.telemetry.can.adBlueLevel"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "firmware",
|
|
||||||
label: "Прошивка",
|
|
||||||
title: "Версия программного обеспечения",
|
|
||||||
description: "Версию и доступность обновления показываем, но запуск обновления для пилотного B2 запрещён. Этот запрет не снимается включением обычного командного канала.",
|
|
||||||
access: "protected",
|
|
||||||
fields: [
|
|
||||||
field("firmware-current", "Текущая версия", "reported.firmware.currentVersion"),
|
|
||||||
field("firmware-applied", "Версия применена", "reported.firmware.appliedAt", { valueKind: "date" }),
|
|
||||||
field("firmware-available", "Доступная версия", "reported.firmware.availableVersion"),
|
|
||||||
field("firmware-description", "Описание версии", "reported.firmware.description"),
|
|
||||||
protectedField("firmware-action", "Обновление прошивки", "policies.firmwareUpdate", { description: "Заблокировано для пилотного устройства" }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "templates",
|
|
||||||
label: "Шаблоны",
|
|
||||||
title: "Шаблоны настроек",
|
|
||||||
description: "Шаблон хранит именованный снимок конфигурации модели. Применение должно создавать новую desired-ревизию, а не менять устройство в обход command ledger.",
|
|
||||||
access: "managed",
|
|
||||||
fields: [
|
|
||||||
field("template-current", "Применённый шаблон", "reported.configurationTemplate.name"),
|
|
||||||
field("template-applied", "Шаблон применён", "reported.configurationTemplate.appliedAt", { valueKind: "date" }),
|
|
||||||
managed("template-select", "Выбранный шаблон", "reported.configurationTemplate.selected"),
|
|
||||||
managed("template-name", "Название нового шаблона", "reported.configurationTemplate.draft.name"),
|
|
||||||
managed("template-description", "Описание нового шаблона", "reported.configurationTemplate.draft.description"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "monitoring",
|
|
||||||
label: "Серверы",
|
|
||||||
title: "Серверы мониторинга",
|
|
||||||
description: "B2 поддерживает четыре серверных слота. Существующий Gelios сохраняется параллельно; новый маршрут не должен его перетирать.",
|
|
||||||
access: "managed",
|
|
||||||
fields: [1, 2, 3, 4].flatMap(serverFields),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "transmission",
|
|
||||||
label: "Передача",
|
|
||||||
title: "Набор передаваемых данных",
|
|
||||||
description: "Флаги определяют состав телеметрии, которую формирует устройство.",
|
|
||||||
access: "managed",
|
|
||||||
fields: [
|
|
||||||
managed("tx-nav-position", "Навигация: широта и долгота", "reported.configuration.transmission.navigation.position", { valueKind: "boolean" }),
|
|
||||||
managed("tx-nav-motion", "Навигация: скорость, высота, спутники и курс", "reported.configuration.transmission.navigation.motion", { valueKind: "boolean" }),
|
|
||||||
managed("tx-nav-hdop", "Навигация: HDOP", "reported.configuration.transmission.navigation.hdop", { valueKind: "boolean" }),
|
|
||||||
managed("tx-gsm-operator", "GSM: сигнал и оператор", "reported.configuration.transmission.gsm.operator", { valueKind: "boolean" }),
|
|
||||||
managed("tx-gsm-cell", "GSM: LAC и CID", "reported.configuration.transmission.gsm.cell", { valueKind: "boolean" }),
|
|
||||||
managed("tx-system-status", "Системные: ошибки и статусы", "reported.configuration.transmission.system.status", { valueKind: "boolean" }),
|
|
||||||
managed("tx-system-io", "Системные: входы, выходы и модули", "reported.configuration.transmission.system.io", { valueKind: "boolean" }),
|
|
||||||
managed("tx-system-voltage", "Системные: напряжения", "reported.configuration.transmission.system.voltage", { valueKind: "boolean" }),
|
|
||||||
...["statuses", "engineHours", "odometer", "fuelTotal", "fuelLevel", "rpm", "engineTemperature", "vehicleSpeed", "axlePressure", "crashController", "instantFuel", "adBlueLevel"].map((key) => managed(`tx-can-${key}`, `CAN: ${({ statuses: "статусы работы", engineHours: "моточасы", odometer: "пробег", fuelTotal: "полный расход топлива", fuelLevel: "уровень топлива", rpm: "обороты двигателя", engineTemperature: "температура двигателя", vehicleSpeed: "скорость", axlePressure: "давление на оси", crashController: "контроллер аварии", instantFuel: "моментальный расход", adBlueLevel: "уровень AdBlue" } as Record<string, string>)[key]}`, `reported.configuration.transmission.can.${key}`, { valueKind: "boolean" })),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "trajectory",
|
|
||||||
label: "Траектория",
|
|
||||||
title: "Отрисовка траектории и датчик движения",
|
|
||||||
description: "Обычные и роуминговые интервалы, заморозка координат и параметры встроенного датчика движения.",
|
|
||||||
access: "managed",
|
|
||||||
fields: [
|
|
||||||
...["normal", "roaming"].flatMap((mode) => {
|
|
||||||
const label = mode === "normal" ? "Основной режим" : "Роуминг";
|
|
||||||
return [
|
|
||||||
managed(`${mode}-course`, `${label}: изменение курса`, `reported.configuration.trajectory.${mode}.courseDeltaDegrees`, { valueKind: "number", unit: "°" }),
|
|
||||||
managed(`${mode}-speed`, `${label}: изменение скорости`, `reported.configuration.trajectory.${mode}.speedDeltaKph`, { valueKind: "number", unit: "км/ч" }),
|
|
||||||
managed(`${mode}-distance`, `${label}: расстояние между точками`, `reported.configuration.trajectory.${mode}.distanceMeters`, { valueKind: "number", unit: "м" }),
|
|
||||||
managed(`${mode}-parking`, `${label}: интервал на стоянке`, `reported.configuration.trajectory.${mode}.parkingIntervalSeconds`, { valueKind: "number", unit: "с" }),
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
managed("freeze-low-speed", "Заморозка координат при скорости ниже 2 км/ч", "reported.configuration.trajectory.freeze.lowSpeed", { valueKind: "boolean" }),
|
|
||||||
managed("freeze-motion", "Заморозка по датчику движения", "reported.configuration.trajectory.freeze.motionSensor", { valueKind: "boolean" }),
|
|
||||||
managed("freeze-ignition", "Заморозка по зажиганию", "reported.configuration.trajectory.freeze.ignition", { valueKind: "boolean" }),
|
|
||||||
managed("freeze-quiet", "Тихоходная техника", "reported.configuration.trajectory.freeze.lowSpeedVehicle", { valueKind: "boolean" }),
|
|
||||||
managed("motion-sensitivity", "Чувствительность датчика движения", "reported.configuration.motionSensor.sensitivity", { valueKind: "number" }),
|
|
||||||
managed("motion-delay", "Задержка срабатывания", "reported.configuration.motionSensor.delaySeconds", { valueKind: "number", unit: "с" }),
|
|
||||||
managed("motion-impact", "Порог удара", "reported.configuration.motionSensor.impact", { valueKind: "number" }),
|
|
||||||
managed("motion-tilt", "Порог наклона", "reported.configuration.motionSensor.tilt", { valueKind: "number" }),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "io",
|
|
||||||
label: "Входы / выходы",
|
|
||||||
title: "Входы и выходы",
|
|
||||||
description: "Режимы PIN0–PIN7 и пороги. Непосредственное переключение выходов относится к защищённым командам.",
|
|
||||||
access: "managed",
|
|
||||||
fields: [
|
|
||||||
...managedIndexedFields(8, "pin-mode", "Режим PIN", "reported.configuration.io.pinModes"),
|
|
||||||
managed("speed-coefficient", "Коэффициент датчика скорости", "reported.configuration.io.speedSensorCoefficient", { valueKind: "number" }),
|
|
||||||
managed("virtual-ignition", "Порог виртуального зажигания", "reported.configuration.io.virtualIgnitionThresholdMv", { valueKind: "number", unit: "мВ" }),
|
|
||||||
managed("analog-pin-2", "Порог аналогового входа PIN2", "reported.configuration.io.analogThresholds.pin2Mv", { valueKind: "number", unit: "мВ" }),
|
|
||||||
managed("analog-pin-3", "Порог аналогового входа PIN3", "reported.configuration.io.analogThresholds.pin3Mv", { valueKind: "number", unit: "мВ" }),
|
|
||||||
protectedField("output-4", "Команда выхода PIN4", "reported.operations.outputs.pin4"),
|
|
||||||
protectedField("output-5", "Команда выхода PIN5", "reported.operations.outputs.pin5"),
|
|
||||||
protectedField("output-6", "Команда выхода PIN6", "reported.operations.outputs.pin6"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ports",
|
|
||||||
label: "Порты",
|
|
||||||
title: "Цифровые порты и датчики",
|
|
||||||
description: "RS232, RS485, CAN, 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<string, DeviceProfileCatalog>([
|
|
||||||
[ARUSNAVI_B2_CATALOG.profileRef, ARUSNAVI_B2_CATALOG],
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function getDeviceProfileCatalog(profileRef: string): DeviceProfileCatalog {
|
|
||||||
return catalogs.get(profileRef) ?? { ...genericCatalog, profileRef };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accessLabel(access: DeviceFieldAccess) {
|
|
||||||
return ({
|
|
||||||
"read-only": "Только чтение",
|
|
||||||
managed: "Управляемая настройка",
|
|
||||||
protected: "Защищённая операция",
|
|
||||||
})[access];
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { applyNodedcTheme } from "@nodedc/ui-core";
|
|
||||||
import "@nodedc/ui-core/styles.css";
|
|
||||||
import "./styles.css";
|
|
||||||
import { DeviceManagerApp } from "./DeviceManagerApp";
|
|
||||||
|
|
||||||
applyNodedcTheme(document.documentElement, {
|
|
||||||
theme: "dark",
|
|
||||||
accent: [185, 255, 74],
|
|
||||||
});
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<DeviceManagerApp />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
export type HubRole = "viewer" | "member" | "admin" | "owner";
|
|
||||||
export type ScopeKind = "company" | "personal";
|
|
||||||
|
|
||||||
export interface OwnerScopeClaim {
|
|
||||||
scopeKind: ScopeKind;
|
|
||||||
ownerRef: string;
|
|
||||||
displayName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceManagerSession {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
displayName: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
initials: string;
|
|
||||||
};
|
|
||||||
actor: {
|
|
||||||
userRef: string;
|
|
||||||
hubRole: HubRole;
|
|
||||||
groupRefs: string[];
|
|
||||||
ownerScopes: OwnerScopeClaim[];
|
|
||||||
};
|
|
||||||
profileUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectSummary {
|
|
||||||
projectRef: string;
|
|
||||||
projectKey: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
lifecycleState: string;
|
|
||||||
ownerScope: {
|
|
||||||
ownerScopeRef: string;
|
|
||||||
scopeKind: ScopeKind;
|
|
||||||
ownerRef: string;
|
|
||||||
displayName: string;
|
|
||||||
};
|
|
||||||
access: {
|
|
||||||
projectRole: string | null;
|
|
||||||
capabilities: string[];
|
|
||||||
};
|
|
||||||
counts: { devices: number; collections: number; discoveries: number };
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceView {
|
|
||||||
deviceRef: string;
|
|
||||||
deviceKey: string | null;
|
|
||||||
displayName: string;
|
|
||||||
modelProfileRef: string;
|
|
||||||
lifecycleState: string;
|
|
||||||
identifier: { kind: string; masked: string } | null;
|
|
||||||
session: { state: string; lastSeenAt: string | null } | null;
|
|
||||||
reported?: {
|
|
||||||
observedAt?: string | null;
|
|
||||||
identity?: Record<string, unknown>;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
firmware?: Record<string, unknown>;
|
|
||||||
configuration?: Record<string, unknown>;
|
|
||||||
telemetry?: Record<string, unknown>;
|
|
||||||
diagnostics?: Record<string, unknown>;
|
|
||||||
operations?: Record<string, unknown>;
|
|
||||||
} | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CollectionView {
|
|
||||||
collectionRef: string;
|
|
||||||
collectionKey: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
lifecycleState: string;
|
|
||||||
memberCount: number;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiscoveryView {
|
|
||||||
discoveryRef: string;
|
|
||||||
identifier: { kind: string; masked: string };
|
|
||||||
modelProfileRef: string;
|
|
||||||
protocol: string;
|
|
||||||
lifecycleState: string;
|
|
||||||
enrollmentIntentRef: string | null;
|
|
||||||
claimedDeviceRef: string | null;
|
|
||||||
firstObservedAt: string | null;
|
|
||||||
lastObservedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnrollmentView {
|
|
||||||
enrollmentIntentRef: string;
|
|
||||||
enrollmentKey: string;
|
|
||||||
displayName: string;
|
|
||||||
modelProfileRef: string;
|
|
||||||
expectedIdentifier: { kind: string; masked: string };
|
|
||||||
lifecycleState: string;
|
|
||||||
observedDiscoveryRef: string | null;
|
|
||||||
claimedDeviceRef: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterPackageView {
|
|
||||||
adapterPackageRef: string;
|
|
||||||
packageKey: string;
|
|
||||||
displayName: string;
|
|
||||||
publisherRef: string;
|
|
||||||
lifecycleState: string;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterVersionView {
|
|
||||||
adapterVersionRef: string;
|
|
||||||
adapterPackageRef: string;
|
|
||||||
version: string;
|
|
||||||
runtimePackageRef: string;
|
|
||||||
contentDigest: string;
|
|
||||||
contractVersion: string;
|
|
||||||
capabilities: string[];
|
|
||||||
lifecycleState: string;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelProfileView {
|
|
||||||
modelProfileRef: string;
|
|
||||||
adapterVersionRef: string | null;
|
|
||||||
schemaVersion: string;
|
|
||||||
vendor: string;
|
|
||||||
model: string;
|
|
||||||
deviceType: string;
|
|
||||||
protocol: string;
|
|
||||||
schemaArtifactRef: string | null;
|
|
||||||
profileDigest: string | null;
|
|
||||||
capabilities: string[];
|
|
||||||
lifecycleState: string;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EdgeView {
|
|
||||||
edgeRef: string;
|
|
||||||
edgeKey: string;
|
|
||||||
displayName: string;
|
|
||||||
deploymentRef: string | null;
|
|
||||||
lifecycleState: string;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteView {
|
|
||||||
routeRef: string;
|
|
||||||
routeKey: string;
|
|
||||||
displayName: string;
|
|
||||||
edgeRef: string;
|
|
||||||
edgeName: string;
|
|
||||||
modelProfileRef: string;
|
|
||||||
profileName: string;
|
|
||||||
listenerRef: string;
|
|
||||||
protocol: string;
|
|
||||||
direction: "telemetry" | "bidirectional";
|
|
||||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
|
||||||
sessionCount: number;
|
|
||||||
activeSessionCount: number;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionView {
|
|
||||||
sessionRef: string;
|
|
||||||
routeRef: string;
|
|
||||||
routeName: string;
|
|
||||||
deviceRef: string | null;
|
|
||||||
deviceName: string | null;
|
|
||||||
protocol: string;
|
|
||||||
lifecycleState: string;
|
|
||||||
connectedAt: string | null;
|
|
||||||
lastSeenAt: string | null;
|
|
||||||
disconnectedAt: string | null;
|
|
||||||
closeReasonCode: string | null;
|
|
||||||
frameCount: number;
|
|
||||||
byteCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindingView {
|
|
||||||
bindingRef: string;
|
|
||||||
bindingKey: string;
|
|
||||||
displayName: string;
|
|
||||||
source: { kind: string; ref: string; displayName: string };
|
|
||||||
target: { kind: string; ref: string };
|
|
||||||
capabilities: string[];
|
|
||||||
lifecycleState: string;
|
|
||||||
sourceApprovedAt: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigurationRevisionView {
|
|
||||||
configurationRevisionRef: string;
|
|
||||||
deviceRef: string;
|
|
||||||
deviceName: string;
|
|
||||||
revisionNumber: number;
|
|
||||||
modelProfileRef: string;
|
|
||||||
schemaArtifactRef: string;
|
|
||||||
configurationDigest: string;
|
|
||||||
changeSummary: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigurationStateView {
|
|
||||||
deviceRef: string;
|
|
||||||
deviceName: string;
|
|
||||||
desiredConfigurationRevisionRef: string | null;
|
|
||||||
appliedConfigurationRevisionRef: string | null;
|
|
||||||
appliedAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandView {
|
|
||||||
commandRef: string;
|
|
||||||
deviceRef: string;
|
|
||||||
deviceName: string;
|
|
||||||
commandKey: string;
|
|
||||||
commandCatalogRef: string;
|
|
||||||
commandType: string;
|
|
||||||
riskClass: string;
|
|
||||||
lifecycleState: string;
|
|
||||||
plannedAt: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
confirmedAt: string | null;
|
|
||||||
dispatchedAt: string | null;
|
|
||||||
acknowledgedAt: string | null;
|
|
||||||
terminalAt: string | null;
|
|
||||||
terminalReasonCode: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
updatedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditEventView {
|
|
||||||
auditEventRef: string;
|
|
||||||
eventType: string;
|
|
||||||
actorRef: string;
|
|
||||||
deviceRef: string | null;
|
|
||||||
discoveryRef: string | null;
|
|
||||||
occurredAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectGrantView {
|
|
||||||
grantRef: string;
|
|
||||||
principalKind: "user" | "group";
|
|
||||||
principalRef: string;
|
|
||||||
projectRole: string;
|
|
||||||
capabilityAllow: string[];
|
|
||||||
capabilityDeny: string[];
|
|
||||||
lifecycleState: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectWorkspace {
|
|
||||||
project: ProjectSummary;
|
|
||||||
devices: DeviceView[];
|
|
||||||
collections: CollectionView[];
|
|
||||||
discoveries: DiscoveryView[];
|
|
||||||
enrollments: EnrollmentView[];
|
|
||||||
adapterPackages: AdapterPackageView[];
|
|
||||||
adapterVersions: AdapterVersionView[];
|
|
||||||
modelProfiles: ModelProfileView[];
|
|
||||||
edges: EdgeView[];
|
|
||||||
routes: RouteView[];
|
|
||||||
sessions: SessionView[];
|
|
||||||
bindings: BindingView[];
|
|
||||||
configurationRevisions: ConfigurationRevisionView[];
|
|
||||||
configurationStates: ConfigurationStateView[];
|
|
||||||
commands: CommandView[];
|
|
||||||
auditEvents: AuditEventView[];
|
|
||||||
grants: ProjectGrantView[];
|
|
||||||
policies: {
|
|
||||||
commandTransport: "disabled" | "typed-service-ping-v1";
|
|
||||||
commandPlanningApi: "disabled" | "enabled";
|
|
||||||
identifierProjection: string;
|
|
||||||
auditPayloadProjection: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"types": ["vite/client"]
|
|
||||||
},
|
|
||||||
"include": ["src", "vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
build: { sourcemap: true },
|
|
||||||
});
|
|
||||||
Generated
-21
@@ -43,23 +43,6 @@
|
|||||||
"vite-plugin-static-copy": "^4.1.1"
|
"vite-plugin-static-copy": "^4.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/device-manager": {
|
|
||||||
"name": "@nodedc/device-manager",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodedc/ui-core": "0.7.0",
|
|
||||||
"@nodedc/ui-react": "0.7.0",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/react": "^19.1.0",
|
|
||||||
"@types/react-dom": "^19.1.0",
|
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vite": "^7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -938,10 +921,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nodedc/device-manager": {
|
|
||||||
"resolved": "apps/device-manager",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@nodedc/map-cesium-react": {
|
"node_modules/@nodedc/map-cesium-react": {
|
||||||
"resolved": "packages/map-cesium-react",
|
"resolved": "packages/map-cesium-react",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
Reference in New Issue
Block a user