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