feat(device-manager): add safe device enrollment
This commit is contained in:
@@ -89,6 +89,7 @@ export function createLocalPreviewDeviceCore() {
|
||||
const modelProfiles = new Map();
|
||||
const edges = new Map();
|
||||
const routes = new Map();
|
||||
const enrollments = new Map();
|
||||
const bindings = new Map();
|
||||
const grants = new Map();
|
||||
const configurationRevisions = new Map();
|
||||
@@ -131,7 +132,7 @@ export function createLocalPreviewDeviceCore() {
|
||||
project: projectSummary(project),
|
||||
devices: [],
|
||||
discoveries: [],
|
||||
enrollments: [],
|
||||
enrollments: projectValues(enrollments, projectRef),
|
||||
collections: projectValues(collections, projectRef)
|
||||
.map(({ projectRef: _projectRef, ...collection }) => collection),
|
||||
adapterPackages: [...adapterPackages.values()],
|
||||
@@ -268,6 +269,12 @@ export function createLocalPreviewDeviceCore() {
|
||||
);
|
||||
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,
|
||||
@@ -282,15 +289,25 @@ export function createLocalPreviewDeviceCore() {
|
||||
}
|
||||
if (command === "adapter-versions:register") {
|
||||
requirePlatformOwner(actor);
|
||||
if (!adapterPackages.has(input.adapterPackageRef)) {
|
||||
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,
|
||||
@@ -308,10 +325,21 @@ export function createLocalPreviewDeviceCore() {
|
||||
}
|
||||
if (command === "model-profiles:register") {
|
||||
requirePlatformOwner(actor);
|
||||
if (!adapterVersions.has(input.adapterVersionRef)) {
|
||||
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,
|
||||
@@ -323,7 +351,7 @@ export function createLocalPreviewDeviceCore() {
|
||||
schemaArtifactRef: input.schemaArtifactRef,
|
||||
profileDigest: input.profileDigest,
|
||||
capabilities: input.capabilities ?? [],
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
lifecycleState,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
updatedAt: now(),
|
||||
};
|
||||
@@ -336,6 +364,12 @@ export function createLocalPreviewDeviceCore() {
|
||||
(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,
|
||||
@@ -360,6 +394,19 @@ export function createLocalPreviewDeviceCore() {
|
||||
(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,
|
||||
@@ -372,7 +419,7 @@ export function createLocalPreviewDeviceCore() {
|
||||
listenerRef: input.listenerRef,
|
||||
protocol: input.protocol,
|
||||
direction: input.direction ?? "telemetry",
|
||||
lifecycleState: input.lifecycleState ?? "draft",
|
||||
lifecycleState,
|
||||
sessionCount: 0,
|
||||
activeSessionCount: 0,
|
||||
createdAt: existing?.createdAt || now(),
|
||||
@@ -432,7 +479,62 @@ export function createLocalPreviewDeviceCore() {
|
||||
throw serviceError("device_configuration_revision_not_found", 404);
|
||||
}
|
||||
if (command === "enrollment-intents:ensure") {
|
||||
throw serviceError("device_enrollment_secure_input_required", 409);
|
||||
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);
|
||||
@@ -449,6 +551,7 @@ export function createLocalPreviewDeviceCore() {
|
||||
modelProfiles,
|
||||
edges,
|
||||
routes,
|
||||
enrollments,
|
||||
bindings,
|
||||
grants,
|
||||
configurationRevisions,
|
||||
@@ -469,6 +572,37 @@ function requirePlatformOwner(actor) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -122,7 +122,7 @@ test("local preview is empty and creates resources only through canonical comman
|
||||
deploymentRef: "deployment:preview-edge",
|
||||
lifecycleState: "provisioning",
|
||||
});
|
||||
await client.execute("routes:ensure", actor, {
|
||||
const route = await client.execute("routes:ensure", actor, {
|
||||
projectRef,
|
||||
routeKey: "preview-route",
|
||||
displayName: "Preview route",
|
||||
@@ -133,6 +133,56 @@ test("local preview is empty and creates resources only through canonical comman
|
||||
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, {
|
||||
@@ -161,6 +211,8 @@ test("local preview is empty and creates resources only through canonical comman
|
||||
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"));
|
||||
|
||||
@@ -76,6 +76,14 @@ export function DeviceControlView({
|
||||
close();
|
||||
await onRefresh();
|
||||
};
|
||||
const mutateAndRefresh = async (mutation: () => Promise<unknown>) => {
|
||||
try {
|
||||
await mutation();
|
||||
await onRefresh();
|
||||
} catch (reason) {
|
||||
onError(reason);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,6 +94,28 @@ export function DeviceControlView({
|
||||
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" ? (
|
||||
@@ -95,6 +125,23 @@ export function DeviceControlView({
|
||||
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}
|
||||
@@ -185,12 +232,14 @@ export function DeviceControlView({
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, onCreateProfile }: {
|
||||
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>
|
||||
@@ -212,6 +261,26 @@ function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, o
|
||||
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>
|
||||
@@ -236,12 +305,14 @@ function CatalogView({ workspace, canManage, onCreatePackage, onCreateVersion, o
|
||||
);
|
||||
}
|
||||
|
||||
function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCreateEdge, onCreateRoute }: {
|
||||
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>
|
||||
@@ -265,6 +336,9 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
|
||||
route.listenerRef,
|
||||
`${route.activeSessionCount}/${route.sessionCount} активных сессий`,
|
||||
]}
|
||||
action={canManageRoutes && ["draft", "suspended"].includes(route.lifecycleState) ? (
|
||||
<Button size="compact" variant="primary" onClick={() => onActivateRoute(route)}>Активировать</Button>
|
||||
) : null}
|
||||
/>
|
||||
))}
|
||||
</ResourceGrid>
|
||||
@@ -279,6 +353,9 @@ function InfrastructureView({ workspace, canManageCatalog, canManageRoutes, onCr
|
||||
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>
|
||||
@@ -741,8 +818,8 @@ function ResourceGrid({ children, empty }: { children: ReactNode; empty: string
|
||||
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 }: { eyebrow: string; title: string; description: string; status: string; meta: string[] }) {
|
||||
return <SettingsCard eyebrow={eyebrow} title={title} description={description} actions={<StatusBadge tone={statusTone(status)}>{status}</StatusBadge>}>
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
claimDevice,
|
||||
ensureCollection,
|
||||
ensureEnrollmentIntent,
|
||||
ensureOwnerScope,
|
||||
ensureProject,
|
||||
loadProjects,
|
||||
@@ -74,6 +75,7 @@ export function DeviceManagerApp() {
|
||||
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 () => {
|
||||
@@ -131,6 +133,7 @@ export function DeviceManagerApp() {
|
||||
session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
|
||||
);
|
||||
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),
|
||||
@@ -279,11 +282,13 @@ export function DeviceManagerApp() {
|
||||
view={activeView}
|
||||
workspace={workspace}
|
||||
canManageCollections={canManageCollections}
|
||||
canEnroll={canEnroll}
|
||||
canClaim={canClaim}
|
||||
session={session}
|
||||
onRefresh={refreshWorkspace}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
onCreateCollection={() => setCollectionDialogOpen(true)}
|
||||
onCreateEnrollment={() => setEnrollmentDialogOpen(true)}
|
||||
onClaim={setClaimEnrollment}
|
||||
/>
|
||||
</ApplicationPanel>
|
||||
@@ -321,6 +326,16 @@ export function DeviceManagerApp() {
|
||||
}}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
/>
|
||||
<EnrollmentDialog
|
||||
open={enrollmentDialogOpen}
|
||||
workspace={workspace}
|
||||
onClose={() => setEnrollmentDialogOpen(false)}
|
||||
onCreated={async () => {
|
||||
setEnrollmentDialogOpen(false);
|
||||
await refreshWorkspace();
|
||||
}}
|
||||
onError={(reason) => setError(errorText(reason))}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -443,15 +458,17 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectView({ view, workspace, canManageCollections, canClaim, session, onRefresh, onError, onCreateCollection, onClaim }: {
|
||||
function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onError, onCreateCollection, onCreateEnrollment, onClaim }: {
|
||||
view: ViewId;
|
||||
workspace: ProjectWorkspace | null;
|
||||
canManageCollections: boolean;
|
||||
canEnroll: boolean;
|
||||
canClaim: boolean;
|
||||
session: DeviceManagerSession;
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (reason: unknown) => void;
|
||||
onCreateCollection: () => void;
|
||||
onCreateEnrollment: () => void;
|
||||
onClaim: (enrollment: EnrollmentView) => void;
|
||||
}) {
|
||||
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
|
||||
@@ -478,6 +495,17 @@ function ProjectView({ view, workspace, canManageCollections, canClaim, session,
|
||||
);
|
||||
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}
|
||||
@@ -636,6 +664,85 @@ function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
|
||||
</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) {
|
||||
|
||||
@@ -68,6 +68,18 @@ export async function claimDevice(input: {
|
||||
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";
|
||||
|
||||
@@ -153,8 +153,8 @@ export interface RouteView {
|
||||
profileName: string;
|
||||
listenerRef: string;
|
||||
protocol: string;
|
||||
direction: string;
|
||||
lifecycleState: string;
|
||||
direction: "telemetry" | "bidirectional";
|
||||
lifecycleState: "draft" | "active" | "suspended" | "retired";
|
||||
sessionCount: number;
|
||||
activeSessionCount: number;
|
||||
createdAt: string | null;
|
||||
|
||||
Reference in New Issue
Block a user