feat(device-manager): add standalone project workspace

This commit is contained in:
Codex
2026-08-13 11:37:36 +03:00
parent 6c1265b8b6
commit 4116f5ba95
17 changed files with 2584 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# 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 only. Digests and credential references stay
inside Device Control Core.
Hub currently supplies identity and groups but no signed company-membership/owner-scope
claim. Therefore an admin may create projects in their personal scope. Existing company
projects remain visible through explicit project grants, but company project creation stays
closed until Hub extends the handoff contract.
## Local source preview
The preview store starts empty and exists only to exercise the shell without a deployed Core.
All visible resources must still be created through the same command-shaped BFF endpoints.
It is forbidden when `NODE_ENV=production`.
```sh
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
npm run build --workspace @nodedc/device-manager
NODEDC_DEVICE_MANAGER_LOCAL_PREVIEW=1 \
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED=0 \
npm run serve --workspace @nodedc/device-manager
```
Production additionally requires:
- `NODEDC_LAUNCHER_BASE_URL`
- `NODEDC_LAUNCHER_INTERNAL_URL`
- `NODEDC_INTERNAL_ACCESS_TOKEN` or `NODEDC_PLATFORM_SERVICE_TOKEN`
- `NODEDC_DEVICE_CORE_INTERNAL_URL`
- `NODEDC_DEVICE_CORE_TOKEN_FILE`
The application source does not create a Hub service entry, DNS record, reverse proxy,
database or deployment artifact. Those remain explicit infrastructure phases.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#101114" />
<title>NODE.DC Device Core</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"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"
}
}
@@ -0,0 +1,232 @@
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"],
["devices:claim", "/internal/v1/management/devices:claim"],
]);
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() {
const ownerScopes = new Map();
const projects = new Map();
const collections = new Map();
function projectSummary(project) {
const projectCollections = [...collections.values()]
.filter((collection) => collection.projectRef === project.projectRef);
return {
...project,
counts: { devices: 0, collections: projectCollections.length, discoveries: 0 },
};
}
return {
configured: true,
async listProjects() {
return [...projects.values()].map(projectSummary);
},
async getWorkspace(_actor, projectRef) {
const project = projects.get(projectRef);
if (!project) throw serviceError("device_project_not_found", 404);
return {
project: projectSummary(project),
devices: [],
discoveries: [],
enrollments: [],
collections: [...collections.values()]
.filter((collection) => collection.projectRef === projectRef)
.map(({ projectRef: _projectRef, ...collection }) => collection),
};
},
async execute(command, actor, input) {
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);
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);
return { replayed: false, result: { created: !existing, collection } };
}
if (command === "devices:claim") {
throw serviceError("device_discovery_not_found", 404);
}
throw serviceError("device_manager_command_invalid", 404);
},
snapshot() {
return { ownerScopes, projects, collections };
},
};
}
const ownerCapabilities = Object.freeze([
"project.read",
"project.manage",
"access.manage",
"inventory.read",
"device.enroll",
"device.claim",
"device.transfer",
"collection.manage",
"route.manage",
"binding.manage",
"telemetry.observe",
"configuration.read",
"configuration.manage",
"command.plan",
"command.confirm",
"command.dispatch",
"credential.manage",
"audit.read",
]);
function actorHeaders(actor) {
if (!actor || typeof actor !== "object") throw serviceError("device_actor_required", 401);
return {
"X-NODEDC-User-Ref": actor.userRef,
"X-NODEDC-Hub-Role": actor.hubRole,
"X-NODEDC-Group-Refs": (actor.groupRefs ?? []).join(","),
"X-NODEDC-Owner-Scopes": (actor.ownerScopes ?? [])
.map((scope) => `${scope.scopeKind}=${scope.ownerRef}`)
.join(","),
};
}
function entityId(value, prefix) {
const match = String(value || "").match(new RegExp(
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
"i",
));
if (!match) throw serviceError(`device_${prefix}_ref_invalid`, 400);
return match[1].toLowerCase();
}
function normalizeBaseUrl(value) {
if (typeof value !== "string" || value.trim() === "") {
throw serviceError("device_core_url_required", 503);
}
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) {
throw serviceError("device_core_url_invalid", 503);
}
url.pathname = url.pathname.replace(/\/$/, "") || "/";
return url;
}
function safeCoreError(value) {
return typeof value === "string" && /^device_[a-z0-9._:-]{2,120}$/.test(value)
? value
: "device_core_unavailable";
}
function serviceError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -0,0 +1,103 @@
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" }],
});
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 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.deepEqual(workspace.devices, []);
});
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
@@ -0,0 +1,403 @@
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,
} = {}) {
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(
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,
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,
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.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)) return redirect(response, loginUrl.toString());
sendJson(response, 401, {
ok: false,
error: "device_manager_auth_required",
loginUrl: loginUrl.toString(),
});
return true;
}
const access = resolveAccess(session.user);
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 access = request.nodedcDeviceManagerAccess ?? resolveAccess(user);
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}`;
const ownerScopes = ["admin", "owner"].includes(access.hubRole)
? [{ scopeKind: "personal", ownerRef: userRef, displayName }]
: [];
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,
},
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,
serviceSlug,
authorize,
currentContext,
handleHandoff,
handleLogout,
};
}
function resolveAccess(user) {
if (!user || typeof user !== "object") {
return { allowed: false, blocked: false, hubRole: "viewer", groups: [] };
}
const groups = normalizedGroups(user);
if (groups.includes("nodedc:device-core:blocked")) {
return { allowed: false, blocked: true, hubRole: "viewer", groups };
}
const id = cleanOpaque(user.id || user.subject || user.sub);
if (!id) return { allowed: false, blocked: false, hubRole: "viewer", 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";
return { allowed: true, blocked: false, hubRole, groups };
}
function normalizedGroups(user) {
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
const groups = [];
for (const value of values) {
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
for (const item of items) {
const raw = typeof item === "string" ? item : item?.name || item?.key || item?.slug;
const normalized = String(raw || "").trim().toLowerCase();
if (/^[a-z0-9][a-z0-9._:-]{1,127}$/.test(normalized)) groups.push(normalized);
}
}
return [...new Set(groups)].sort();
}
function attachSession(request, session) {
request.nodedcDeviceManagerSession = session;
return session;
}
function cleanOpaque(value) {
const normalized = String(value || "").trim();
return /^[A-Za-z0-9][A-Za-z0-9._:-]{2,255}$/.test(normalized)
? normalized
: null;
}
function parseCookies(header = "") {
const values = {};
for (const part of String(header).split(";")) {
const index = part.indexOf("=");
if (index < 1) continue;
const key = part.slice(0, index).trim();
try {
values[key] = decodeURIComponent(part.slice(index + 1).trim());
} catch {
values[key] = part.slice(index + 1).trim();
}
}
return values;
}
function appendCookie(response, value) {
const current = response.getHeader("Set-Cookie");
response.setHeader("Set-Cookie", current ? [current, value].flat() : value);
}
function safeReturnTo(value) {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
? value
: "/";
}
function isHtmlRequest(request, url) {
return request.method === "GET"
&& !url.pathname.startsWith("/api/")
&& (url.pathname === "/" || String(request.headers.accept || "").includes("text/html"));
}
function redirect(response, location) {
response.statusCode = 302;
response.setHeader("Location", location);
response.setHeader("Cache-Control", "no-store");
response.end();
}
function sendJson(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify(body));
}
function sendText(response, status, body) {
response.statusCode = status;
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(body);
}
function initials(value) {
return value.split(/\s+/).filter(Boolean).slice(0, 2)
.map((part) => part[0]).join("").toUpperCase() || "DC";
}
function booleanValue(value, fallback) {
if (value == null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).toLowerCase());
}
function boundedInteger(value, fallback, min, max) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) ? Math.min(max, Math.max(min, parsed)) : fallback;
}
function textValue(value, fallback) {
return String(value || fallback).trim();
}
function baseUrl(value, fallback) {
return textValue(value, fallback).replace(/\/$/, "");
}
function serviceError(code, statusCode) {
const error = new Error(code);
error.statusCode = statusCode;
return error;
}
@@ -0,0 +1,127 @@
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,
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: "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,
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);
}
});
function productionEnv() {
return {
NODE_ENV: "production",
NODEDC_DEVICE_MANAGER_AUTH_REQUIRED: "true",
NODEDC_DEVICE_MANAGER_COOKIE_SECURE: "false",
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
NODEDC_INTERNAL_ACCESS_TOKEN: internalToken,
};
}
function mockResponse() {
const headers = new Map();
return {
statusCode: 200,
body: "",
setHeader(name, value) { headers.set(String(name).toLowerCase(), value); },
getHeader(name) { return headers.get(String(name).toLowerCase()); },
end(body = "") { this.body = String(body); },
};
}
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
@@ -0,0 +1,244 @@
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/devices:claim", "devices:claim"],
]);
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) {
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 auth = createDeviceManagerAuth({ env });
let coreClient;
if (localPreview) {
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
throw new Error("device_manager_local_preview_forbidden");
}
coreClient = createLocalPreviewDeviceCore();
} 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,
}));
});
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createLocalPreviewDeviceCore } from "./device-core-client.mjs";
import { createDeviceManagerAuth } from "./device-manager-auth.mjs";
import { createDeviceManagerServer } from "./device-manager-server.mjs";
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,
});
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.deepEqual(workspace.workspace.devices, []);
const missingKey = await fetch(`${baseUrl}/api/device-manager/projects:ensure`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
assert.equal(missingKey.status, 400);
assert.equal((await missingKey.json()).error, "device_idempotency_key_invalid");
});
async function getJson(url) {
const response = await fetch(url, { headers: { accept: "application/json" } });
const body = await response.json();
assert.equal(response.status, 200, JSON.stringify(body));
assert.equal(body.ok, true);
return body;
}
async function postJson(url, body, headers = {}) {
const response = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
"idempotency-key": `device-manager-test-${crypto.randomUUID()}`,
...headers,
},
body: JSON.stringify(body),
});
const payload = await response.json();
assert.equal(response.status, 200, JSON.stringify(payload));
assert.equal(payload.ok, true);
return payload;
}
@@ -0,0 +1,630 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
GlassSurface,
HeaderNavigation,
HeaderProfile,
HeaderWorkspace,
Icon,
Select,
SettingsCard,
StatusBadge,
TextAreaField,
TextField,
UserProfileMenu,
Window,
WindowFooterActions,
useApplicationWorkspace,
} from "@nodedc/ui-react";
import {
claimDevice,
ensureCollection,
ensureOwnerScope,
ensureProject,
loadProjects,
loadSession,
loadWorkspace,
} from "./api";
import type {
DeviceManagerSession,
EnrollmentView,
OwnerScopeClaim,
ProjectSummary,
ProjectWorkspace,
} from "./types";
type ViewId = "overview" | "inventory" | "discovery" | "collections";
const navigationItems = [
{ id: "overview", label: "Обзор", icon: "grid" },
{ id: "inventory", label: "Устройства", icon: "apps" },
{ id: "discovery", label: "Подключение", icon: "network" },
{ id: "collections", label: "Коллекции", icon: "folder" },
] 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 [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 capabilities = new Set(activeProject?.access.capabilities ?? []);
const canCreateProject = Boolean(
session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
);
const canManageCollections = capabilities.has("collection.manage");
const canClaim = capabilities.has("device.claim");
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();
};
if (loading || !session) {
return <div className="device-manager-boot">Подключаем Device Core</div>;
}
const activeView = shell.activeView ?? "overview";
return (
<>
<ApplicationShell
header={
<AppHeader
brand={<span className="device-manager-brand">NODE.DC</span>}
brandHref="/"
left={
<button
className="device-manager-workspace-trigger"
type="button"
aria-label="Открыть Device Core"
onClick={shell.toggleNavigation}
>
<HeaderWorkspace label="Device Core" />
</button>
}
center={
<HeaderNavigation
label="Device Core"
value="devices"
items={[{ value: "devices", label: "Device Core" }]}
onChange={() => shell.openNavigation()}
/>
}
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
ownerScopes={ownerScopes}
activeOwnerRef={activeOwnerRef}
onOwnerChange={setActiveOwnerRef}
projects={visibleProjects}
canCreateProject={canCreateProject}
onCreateProject={() => setProjectDialogOpen(true)}
onOpenProject={openProject}
error={error}
onDismissError={() => setError(null)}
/>
}
navigationOpen={shell.navigationOpen}
navigation={
<AdminNavigationPanel
eyebrow="DEVICE CORE"
title="Устройства"
contexts={activeProject ? [{
id: activeProject.projectRef,
label: activeProject.name,
description: activeProject.ownerScope.displayName,
active: true,
icon: <Icon name="apps" />,
}] : []}
contextSlot={
ownerScopes.length ? (
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={setActiveOwnerRef}
searchable={ownerScopes.length > 6}
/>
) : null
}
items={activeProject ? navigationItems.map((item) => ({
id: item.id,
label: item.label,
icon: <Icon name={item.icon} />,
})) : []}
activeId={shell.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}
canClaim={canClaim}
onCreateCollection={() => setCollectionDialogOpen(true)}
onClaim={setClaimEnrollment}
/>
</ApplicationPanel>
) : null
}
/>
<ProjectDialog
open={projectDialogOpen}
ownerScope={ownerScopes.find((scope) => scope.ownerRef === activeOwnerRef) ?? null}
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))}
/>
</>
);
}
function DeviceStage({
ownerScopes,
activeOwnerRef,
onOwnerChange,
projects,
canCreateProject,
onCreateProject,
onOpenProject,
error,
onDismissError,
}: {
ownerScopes: OwnerScopeClaim[];
activeOwnerRef: string;
onOwnerChange: (value: string) => void;
projects: ProjectSummary[];
canCreateProject: boolean;
onCreateProject: () => void;
onOpenProject: (projectRef: string, view?: ViewId) => void;
error: string | null;
onDismissError: () => void;
}) {
const totals = projects.reduce(
(sum, project) => ({
devices: sum.devices + project.counts.devices,
collections: sum.collections + project.counts.collections,
discoveries: sum.discoveries + project.counts.discoveries,
}),
{ devices: 0, collections: 0, discoveries: 0 },
);
return (
<div className="device-manager-stage">
<section className="device-manager-hero">
<div>
<span>UNIVERSAL DEVICE PLANE</span>
<h1>Все устройства в одном управляемом контуре.</h1>
<p>Проекты, безопасное подключение, инвентарь и коллекции без привязки интерфейса к конкретному производителю.</p>
</div>
<div className="device-manager-hero__actions">
{ownerScopes.length ? (
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={onOwnerChange}
/>
) : null}
<Button variant="primary" icon={<Icon name="plus" />} disabled={!canCreateProject} onClick={onCreateProject}>
Новый проект
</Button>
</div>
</section>
{error ? (
<div className="device-manager-alert" role="alert">
<Icon name="alert" />
<span>{error}</span>
<Button variant="ghost" size="compact" onClick={onDismissError}>Закрыть</Button>
</div>
) : null}
<section className="device-manager-metrics" aria-label="Состояние контура">
<Metric label="Проекты" value={projects.length} detail="доступно пользователю" />
<Metric label="Устройства" value={totals.devices} detail="в выбранном контуре" />
<Metric label="Подключение" value={totals.discoveries} detail="ожидают решения" tone={totals.discoveries ? "warning" : "success"} />
<Metric label="Коллекции" value={totals.collections} detail="логических групп" />
</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>
</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, canClaim, onCreateCollection, onClaim }: {
view: ViewId;
workspace: ProjectWorkspace | null;
canManageCollections: boolean;
canClaim: boolean;
onCreateCollection: () => void;
onClaim: (enrollment: EnrollmentView) => void;
}) {
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект</div>;
if (view === "inventory") return (
<EntityList
empty="В проекте ещё нет зарегистрированных устройств."
items={workspace.devices.map((device) => ({
id: device.deviceRef,
title: device.displayName,
subtitle: `${device.modelProfileRef} · ${device.identifier?.masked ?? "идентификатор не назначен"}`,
status: device.session?.state || device.lifecycleState,
tone: device.session?.state === "online" ? "success" : "neutral",
}))}
/>
);
if (view === "discovery") return (
<div className="device-manager-stack">
{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, ownerScope, onClose, onCreated, onError }: {
open: boolean;
ownerScope: OwnerScopeClaim | 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 (!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}>
<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 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: "Коллекции" })[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;
}
+87
View File
@@ -0,0 +1,87 @@
import type {
DeviceManagerSession,
ProjectSummary,
ProjectWorkspace,
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);
}
async function mutate(path: string, input: unknown) {
return requestJson<{ ok: true; replayed: boolean; result: unknown }>(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;
}
+17
View File
@@ -0,0 +1,17 @@
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>,
);
+418
View File
@@ -0,0 +1,418 @@
:root {
color-scheme: dark;
background: #0b0d0f;
}
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 {
color: var(--nodedc-text-primary);
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0.15em;
}
.device-manager-workspace-trigger {
display: inline-grid;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.device-manager-stage {
height: 100%;
overflow: auto;
padding: clamp(2.25rem, 5vw, 5.5rem);
scrollbar-width: thin;
}
.device-manager-hero {
display: flex;
max-width: 1420px;
align-items: end;
justify-content: space-between;
gap: 2rem;
margin: 0 auto 2.5rem;
}
.device-manager-hero > div:first-child {
max-width: 780px;
}
.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 {
max-width: 760px;
margin: 0.7rem 0 0.9rem;
color: var(--nodedc-text-primary);
font-size: clamp(2.25rem, 5vw, 5.4rem);
font-weight: 520;
letter-spacing: -0.065em;
line-height: 0.98;
}
.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-hero__actions {
display: flex;
min-width: min(100%, 390px);
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.device-manager-hero__actions .nodedc-select-anchor {
min-width: 210px;
}
.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 {
max-width: 1420px;
margin: 0 auto;
}
.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: 210px;
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-manager-form {
display: grid;
gap: 1rem;
}
.device-manager-form textarea {
min-height: 110px;
resize: vertical;
}
@media (max-width: 1100px) {
.device-manager-hero {
align-items: start;
flex-direction: column;
}
.device-manager-hero__actions {
width: 100%;
justify-content: flex-start;
}
.device-manager-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
body { overflow: auto; }
.device-manager-stage {
padding: 2rem 1rem 4rem;
}
.device-manager-hero h1 {
font-size: clamp(2.25rem, 14vw, 3.5rem);
}
.device-manager-hero__actions,
.device-manager-panel-toolbar,
.device-manager-section-title {
align-items: stretch;
flex-direction: column;
}
.device-manager-hero__actions > *,
.device-manager-hero__actions .nodedc-select-anchor {
width: 100%;
}
.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;
}
}
+103
View File
@@ -0,0 +1,103 @@
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;
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 ProjectWorkspace {
project: ProjectSummary;
devices: DeviceView[];
collections: CollectionView[];
discoveries: DiscoveryView[];
enrollments: EnrollmentView[];
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
build: { sourcemap: true },
});
+21
View File
@@ -43,6 +43,23 @@
"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",
@@ -921,6 +938,10 @@
"@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