feat(device-manager): add safe B2 service ping command
This commit is contained in:
@@ -22,6 +22,7 @@ const commandRoutes = new Map([
|
|||||||
"device-configurations:set-desired",
|
"device-configurations:set-desired",
|
||||||
"/internal/v1/management/device-configurations:set-desired",
|
"/internal/v1/management/device-configurations:set-desired",
|
||||||
],
|
],
|
||||||
|
["commands:service-ping", "/internal/v1/commands:service-ping"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {}) {
|
||||||
@@ -97,6 +98,7 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
const configurationRevisions = new Map();
|
const configurationRevisions = new Map();
|
||||||
const configurationStates = new Map();
|
const configurationStates = new Map();
|
||||||
const auditEvents = [];
|
const auditEvents = [];
|
||||||
|
const commands = new Map();
|
||||||
|
|
||||||
function now() {
|
function now() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -152,12 +154,14 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
bindings: projectValues(bindings, projectRef),
|
bindings: projectValues(bindings, projectRef),
|
||||||
configurationRevisions: projectValues(configurationRevisions, projectRef),
|
configurationRevisions: projectValues(configurationRevisions, projectRef),
|
||||||
configurationStates: projectValues(configurationStates, projectRef),
|
configurationStates: projectValues(configurationStates, projectRef),
|
||||||
commands: [],
|
commands: projectValues(commands, projectRef),
|
||||||
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
|
auditEvents: auditEvents.filter((event) => event.projectRef === projectRef),
|
||||||
grants: projectValues(grants, projectRef),
|
grants: projectValues(grants, projectRef),
|
||||||
policies: {
|
policies: {
|
||||||
commandTransport: "disabled",
|
commandTransport: fixture === "arusnavi-b2"
|
||||||
commandPlanningApi: "disabled",
|
? "typed-service-ping-v1"
|
||||||
|
: "disabled",
|
||||||
|
commandPlanningApi: fixture === "arusnavi-b2" ? "enabled" : "disabled",
|
||||||
identifierProjection: "masked-only",
|
identifierProjection: "masked-only",
|
||||||
auditPayloadProjection: "metadata-only",
|
auditPayloadProjection: "metadata-only",
|
||||||
},
|
},
|
||||||
@@ -187,6 +191,42 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
return workspace(projectRef);
|
return workspace(projectRef);
|
||||||
},
|
},
|
||||||
async execute(command, actor, input) {
|
async execute(command, actor, input) {
|
||||||
|
if (command === "commands:service-ping") {
|
||||||
|
if (fixture !== "arusnavi-b2") {
|
||||||
|
throw serviceError("device_command_transport_disabled", 409);
|
||||||
|
}
|
||||||
|
const device = devices.get(input.deviceRef);
|
||||||
|
if (!device || device.projectRef !== input.projectRef) {
|
||||||
|
throw serviceError("device_command_route_unavailable", 409);
|
||||||
|
}
|
||||||
|
if (typeof input.accessCode !== "string" || !/^\d{6}$/.test(input.accessCode)) {
|
||||||
|
throw serviceError("device_service_ping_access_code_invalid", 400);
|
||||||
|
}
|
||||||
|
const commandRef = `command:${randomUUID()}`;
|
||||||
|
const at = now();
|
||||||
|
const view = {
|
||||||
|
commandRef,
|
||||||
|
projectRef: input.projectRef,
|
||||||
|
deviceRef: input.deviceRef,
|
||||||
|
deviceName: device.displayName,
|
||||||
|
commandKey: `preview-service-ping-${randomUUID()}`,
|
||||||
|
commandCatalogRef: "arusnavi.b2.internal.v1:service-ping",
|
||||||
|
commandType: "service.ping",
|
||||||
|
riskClass: "low",
|
||||||
|
lifecycleState: "queued",
|
||||||
|
plannedAt: at,
|
||||||
|
expiresAt: new Date(Date.now() + Number(input.expiresInSeconds) * 1000).toISOString(),
|
||||||
|
confirmedAt: null,
|
||||||
|
dispatchedAt: null,
|
||||||
|
acknowledgedAt: null,
|
||||||
|
terminalAt: null,
|
||||||
|
terminalReasonCode: null,
|
||||||
|
createdAt: at,
|
||||||
|
updatedAt: at,
|
||||||
|
};
|
||||||
|
commands.set(commandRef, view);
|
||||||
|
return { replayed: false, result: view };
|
||||||
|
}
|
||||||
if (command === "owner-scopes:ensure") {
|
if (command === "owner-scopes:ensure") {
|
||||||
const key = `${input.scopeKind}:${input.ownerRef}`;
|
const key = `${input.scopeKind}:${input.ownerRef}`;
|
||||||
const created = !ownerScopes.has(key);
|
const created = !ownerScopes.has(key);
|
||||||
@@ -580,6 +620,7 @@ export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
|
|||||||
grants,
|
grants,
|
||||||
configurationRevisions,
|
configurationRevisions,
|
||||||
configurationStates,
|
configurationStates,
|
||||||
|
commands,
|
||||||
auditEvents,
|
auditEvents,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ test("explicit B2 preview fixture is isolated from the empty canonical preview",
|
|||||||
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
|
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
|
||||||
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
|
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
|
||||||
assert.equal(workspace.sessions[0].lifecycleState, "online");
|
assert.equal(workspace.sessions[0].lifecycleState, "online");
|
||||||
assert.equal(workspace.policies.commandTransport, "disabled");
|
assert.equal(workspace.policies.commandTransport, "typed-service-ping-v1");
|
||||||
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
|
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
|
||||||
|
|
||||||
assert.throws(
|
assert.throws(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const mutationRoutes = new Map([
|
|||||||
"/api/device-manager/device-configurations:set-desired",
|
"/api/device-manager/device-configurations:set-desired",
|
||||||
"device-configurations:set-desired",
|
"device-configurations:set-desired",
|
||||||
],
|
],
|
||||||
|
["/api/device-manager/commands:service-ping", "commands:service-ping"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createDeviceManagerServer({
|
export function createDeviceManagerServer({
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
registerAdapterVersion,
|
registerAdapterVersion,
|
||||||
registerModelProfile,
|
registerModelProfile,
|
||||||
revokeDeviceBinding,
|
revokeDeviceBinding,
|
||||||
|
sendServicePing,
|
||||||
setDesiredConfiguration,
|
setDesiredConfiguration,
|
||||||
upsertProjectGrant,
|
upsertProjectGrant,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
@@ -157,7 +158,14 @@ export function DeviceControlView({
|
|||||||
}).then(onRefresh).catch(onError)}
|
}).then(onRefresh).catch(onError)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{view === "commands" ? <CommandsView workspace={workspace} /> : null}
|
{view === "commands" ? (
|
||||||
|
<CommandsView
|
||||||
|
workspace={workspace}
|
||||||
|
canDispatch={capabilities.has("command.plan") && capabilities.has("command.dispatch")}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{view === "audit" ? <AuditView workspace={workspace} /> : null}
|
{view === "audit" ? <AuditView workspace={workspace} /> : null}
|
||||||
{view === "access" ? (
|
{view === "access" ? (
|
||||||
<AccessView
|
<AccessView
|
||||||
@@ -412,17 +420,92 @@ function BindingsView({ workspace, canManage, onCreate, onRevoke }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommandsView({ workspace }: { workspace: ProjectWorkspace }) {
|
function CommandsView({ workspace, canDispatch, onRefresh, onError }: {
|
||||||
|
workspace: ProjectWorkspace;
|
||||||
|
canDispatch: boolean;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
onError: (reason: unknown) => void;
|
||||||
|
}) {
|
||||||
|
const supportedDevices = workspace.devices.filter(
|
||||||
|
(device) => device.modelProfileRef === "arusnavi.b2.internal.v1"
|
||||||
|
&& !["suspended", "retired"].includes(device.lifecycleState),
|
||||||
|
);
|
||||||
|
const [deviceRef, setDeviceRef] = useState(supportedDevices[0]?.deviceRef ?? "");
|
||||||
|
const [accessCode, setAccessCode] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const enabled = workspace.policies.commandTransport === "typed-service-ping-v1";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supportedDevices.some((device) => device.deviceRef === deviceRef)) {
|
||||||
|
setDeviceRef(supportedDevices[0]?.deviceRef ?? "");
|
||||||
|
}
|
||||||
|
}, [deviceRef, supportedDevices]);
|
||||||
|
const submit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!enabled || !canDispatch || !deviceRef || !/^\d{6}$/.test(accessCode)) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await sendServicePing({
|
||||||
|
projectRef: workspace.project.projectRef,
|
||||||
|
deviceRef,
|
||||||
|
accessCode,
|
||||||
|
expiresInSeconds: 300,
|
||||||
|
});
|
||||||
|
setAccessCode("");
|
||||||
|
await onRefresh();
|
||||||
|
} catch (reason) {
|
||||||
|
onError(reason);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<ControlStack>
|
<ControlStack>
|
||||||
<div className="device-control-command-policy">
|
<div className="device-control-command-policy">
|
||||||
<Icon name="lock" />
|
<Icon name={enabled ? "check" : "lock"} />
|
||||||
<div>
|
<div>
|
||||||
<strong>Command transport выключен</strong>
|
<strong>{enabled ? "Типизированный командный канал активен" : "Command transport выключен"}</strong>
|
||||||
<p>Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния.</p>
|
<p>{enabled
|
||||||
|
? "Доступна только безопасная проверка сервиса. Произвольные команды, прошивка, очистка памяти и перезагрузка отсутствуют. Код устройства существует только в памяти Core до отправки или истечения TTL."
|
||||||
|
: "Ни UI, ни BFF не имеют raw command builder. acknowledged означает подтверждение протокола, verified — отдельное доказательство состояния."}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone="warning">{workspace.policies.commandTransport}</StatusBadge>
|
<StatusBadge tone={enabled ? "success" : "warning"}>{workspace.policies.commandTransport}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
{enabled ? (
|
||||||
|
<form className="device-control-command-form" onSubmit={submit}>
|
||||||
|
<Select
|
||||||
|
label="B2 трекер"
|
||||||
|
value={deviceRef}
|
||||||
|
onChange={setDeviceRef}
|
||||||
|
options={supportedDevices.map((device) => ({
|
||||||
|
value: device.deviceRef,
|
||||||
|
label: device.displayName,
|
||||||
|
description: device.session?.state || device.lifecycleState,
|
||||||
|
}))}
|
||||||
|
disabled={!canDispatch || supportedDevices.length === 0 || submitting}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Код устройства"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
value={accessCode}
|
||||||
|
onChange={(event) => setAccessCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
minLength={6}
|
||||||
|
maxLength={6}
|
||||||
|
required
|
||||||
|
disabled={!canDispatch || submitting}
|
||||||
|
description="Ровно 6 цифр. Код не сохраняется и не попадает в журнал. Команда истечёт через 5 минут."
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!canDispatch || !deviceRef || accessCode.length !== 6 || submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Ставим в очередь…" : "Проверить сервис"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
|
<ResourceList empty="Command intents отсутствуют. Это не означает, что транспорт доступен.">
|
||||||
{workspace.commands.map((command) => (
|
{workspace.commands.map((command) => (
|
||||||
<ResourceRow
|
<ResourceRow
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ function AccessNotice({
|
|||||||
if (access === "protected") {
|
if (access === "protected") {
|
||||||
return <div className="device-detail-notice" data-access="protected"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></div>;
|
return <div className="device-detail-notice" data-access="protected"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></div>;
|
||||||
}
|
}
|
||||||
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "enabled" ? "Настройка управляется через command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
|
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Настройка управляется через типизированный command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
|
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
|
||||||
|
|||||||
@@ -216,6 +216,15 @@ export async function setDesiredConfiguration(input: {
|
|||||||
return mutate("/api/device-manager/device-configurations:set-desired", input);
|
return mutate("/api/device-manager/device-configurations:set-desired", input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendServicePing(input: {
|
||||||
|
projectRef: string;
|
||||||
|
deviceRef: string;
|
||||||
|
accessCode: string;
|
||||||
|
expiresInSeconds: number;
|
||||||
|
}) {
|
||||||
|
return mutate("/api/device-manager/commands:service-ping", input);
|
||||||
|
}
|
||||||
|
|
||||||
async function mutate<T = unknown>(path: string, input: unknown) {
|
async function mutate<T = unknown>(path: string, input: unknown) {
|
||||||
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
|
return requestJson<{ ok: true; replayed: boolean; result: T }>(path, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -92,6 +92,17 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-control-command-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(181, 255, 44, 0.18);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(181, 255, 44, 0.045);
|
||||||
|
}
|
||||||
|
|
||||||
.device-control-command-policy p {
|
.device-control-command-policy p {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: rgba(255, 255, 255, 0.58);
|
color: rgba(255, 255, 255, 0.58);
|
||||||
@@ -143,6 +154,10 @@
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-control-command-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.device-control-command-policy > .nodedc-status {
|
.device-control-command-policy > .nodedc-status {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export interface ProjectWorkspace {
|
|||||||
auditEvents: AuditEventView[];
|
auditEvents: AuditEventView[];
|
||||||
grants: ProjectGrantView[];
|
grants: ProjectGrantView[];
|
||||||
policies: {
|
policies: {
|
||||||
commandTransport: "disabled" | "enabled";
|
commandTransport: "disabled" | "typed-service-ping-v1";
|
||||||
commandPlanningApi: "disabled" | "enabled";
|
commandPlanningApi: "disabled" | "enabled";
|
||||||
identifierProjection: string;
|
identifierProjection: string;
|
||||||
auditPayloadProjection: string;
|
auditPayloadProjection: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user