feat(device-manager): add full device profile workspace

This commit is contained in:
Codex
2026-08-13 11:38:26 +03:00
parent bbb50e06b4
commit 19f0d97e23
8 changed files with 1416 additions and 15 deletions
@@ -80,7 +80,7 @@ export function createDeviceCoreClient({ baseUrl, token, fetchImpl = fetch } = {
};
}
export function createLocalPreviewDeviceCore() {
export function createLocalPreviewDeviceCore({ fixture = null } = {}) {
const ownerScopes = new Map();
const projects = new Map();
const collections = new Map();
@@ -90,6 +90,8 @@ export function createLocalPreviewDeviceCore() {
const edges = new Map();
const routes = new Map();
const enrollments = new Map();
const devices = new Map();
const sessions = new Map();
const bindings = new Map();
const grants = new Map();
const configurationRevisions = new Map();
@@ -121,7 +123,11 @@ export function createLocalPreviewDeviceCore() {
.filter((collection) => collection.projectRef === project.projectRef);
return {
...project,
counts: { devices: 0, collections: projectCollections.length, discoveries: 0 },
counts: {
devices: projectValues(devices, project.projectRef).length,
collections: projectCollections.length,
discoveries: 0,
},
};
}
@@ -130,7 +136,8 @@ export function createLocalPreviewDeviceCore() {
if (!project) throw serviceError("device_project_not_found", 404);
return {
project: projectSummary(project),
devices: [],
devices: projectValues(devices, projectRef)
.map(({ projectRef: _projectRef, ...device }) => device),
discoveries: [],
enrollments: projectValues(enrollments, projectRef),
collections: projectValues(collections, projectRef)
@@ -140,7 +147,8 @@ export function createLocalPreviewDeviceCore() {
modelProfiles: [...modelProfiles.values()],
edges: [...edges.values()],
routes: projectValues(routes, projectRef),
sessions: [],
sessions: projectValues(sessions, projectRef)
.map(({ projectRef: _projectRef, ...session }) => session),
bindings: projectValues(bindings, projectRef),
configurationRevisions: projectValues(configurationRevisions, projectRef),
configurationStates: projectValues(configurationStates, projectRef),
@@ -156,6 +164,20 @@ export function createLocalPreviewDeviceCore() {
};
}
if (fixture === "arusnavi-b2") seedArusnaviB2Preview({
ownerScopes,
projects,
devices,
modelProfiles,
edges,
routes,
sessions,
configurationStates,
});
else if (fixture != null && fixture !== "") {
throw serviceError("device_manager_preview_fixture_invalid", 400);
}
return {
configured: true,
async listProjects() {
@@ -552,6 +574,8 @@ export function createLocalPreviewDeviceCore() {
edges,
routes,
enrollments,
devices,
sessions,
bindings,
grants,
configurationRevisions,
@@ -572,6 +596,160 @@ function requirePlatformOwner(actor) {
}
}
function seedArusnaviB2Preview({
ownerScopes,
projects,
devices,
modelProfiles,
edges,
routes,
sessions,
configurationStates,
}) {
const timestamp = new Date().toISOString();
const ownerScopeRef = "owner-scope:78da71d5-f48f-4de0-8e47-729f6d644151";
const projectRef = "project:ad7b357c-c7ac-4bf8-a638-c7f956e9aa71";
const deviceRef = "device:b6a55921-7888-44b5-a93e-241aa2fdd3d7";
const edgeRef = "edge:73da0c42-a641-4559-b8f7-23509b60bfe9";
const routeRef = "route:fef9b7a0-a462-4d68-9991-af026203368b";
const sessionRef = "session:57ead610-47de-45f7-a42d-fbe4fa0aba38";
const scope = {
ownerScopeRef,
scopeKind: "personal",
ownerRef: "user:local-device-admin",
displayName: "Local Device Admin",
lifecycleState: "active",
};
ownerScopes.set("personal:user:local-device-admin", scope);
projects.set(projectRef, {
projectRef,
projectKey: "arusnavi-b2-preview",
name: "ARUSNAVI B2 preview",
description: "Локальная визуальная фикстура без реальных идентификаторов и команд",
lifecycleState: "active",
ownerScope: scope,
access: { projectRole: "owner", capabilities: ownerCapabilities },
counts: { devices: 1, collections: 0, discoveries: 0 },
createdAt: timestamp,
updatedAt: timestamp,
});
modelProfiles.set("arusnavi.b2.internal.v1", {
modelProfileRef: "arusnavi.b2.internal.v1",
adapterVersionRef: null,
schemaVersion: "1.0.0",
vendor: "ARUSNAVI",
model: "B2",
deviceType: "tracker",
protocol: "INTERNAL",
schemaArtifactRef: "schema:arusnavi.b2.internal.v1",
profileDigest: null,
capabilities: ["telemetry", "configuration", "commands"],
lifecycleState: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
edges.set(edgeRef, {
edgeRef,
edgeKey: "preview-edge",
displayName: "Preview VPS edge",
deploymentRef: "deployment:preview",
lifecycleState: "active",
createdAt: timestamp,
updatedAt: timestamp,
});
routes.set(routeRef, {
routeRef,
projectRef,
routeKey: "preview-b2-route",
displayName: "B2 direct preview",
edgeRef,
edgeName: "Preview VPS edge",
modelProfileRef: "arusnavi.b2.internal.v1",
profileName: "ARUSNAVI B2",
listenerRef: "listener:preview",
protocol: "INTERNAL",
direction: "bidirectional",
lifecycleState: "active",
sessionCount: 1,
activeSessionCount: 1,
createdAt: timestamp,
updatedAt: timestamp,
});
devices.set(deviceRef, {
projectRef,
deviceRef,
deviceKey: "pilot-b2-preview",
displayName: "Пилотный B2",
modelProfileRef: "arusnavi.b2.internal.v1",
lifecycleState: "active",
identifier: { kind: "imei", masked: "***********0001" },
session: { state: "online", lastSeenAt: timestamp },
reported: {
observedAt: timestamp,
identity: { iccid1: "****************1111", iccid2: "****************2222" },
firmware: { currentVersion: "0.02", appliedAt: timestamp, availableVersion: "0.05" },
configuration: {
monitoring: {
servers: [
{ host: "legacy.example.invalid", port: 20623, protocol: "INTERNAL", identity: "0" },
{ host: "direct.example.invalid", port: 9921, protocol: "INTERNAL", identity: "0" },
],
},
transmission: { navigation: { position: true, motion: true, hdop: false } },
trajectory: {
normal: { courseDeltaDegrees: 15, speedDeltaKph: 10, distanceMeters: 15, parkingIntervalSeconds: 15 },
roaming: { courseDeltaDegrees: 20, speedDeltaKph: 50, distanceMeters: 1000, parkingIntervalSeconds: 300 },
},
navigation: {
sources: { satellite: true, wifi: false, lbs: false, tag: false },
constellations: { gps: true, glonass: true, galileo: false, beidou: false },
filter: { minimumSatellites: 4, maximumHdopTimesTen: 30 },
},
},
telemetry: {
navigation: {
latitude: "55.7500",
longitude: "37.6200",
speedKph: 18,
altitudeMeters: 156,
satellites: 12,
courseDegrees: 84,
hdop: 1.2,
},
gsm: { signal: 79, operator: "preview", lac: "masked", cid: "masked" },
system: { externalVoltageMv: 13240, internalVoltageMv: 4120, status: "Норма" },
},
},
createdAt: timestamp,
updatedAt: timestamp,
});
sessions.set(sessionRef, {
sessionRef,
projectRef,
routeRef,
routeName: "B2 direct preview",
deviceRef,
deviceName: "Пилотный B2",
protocol: "INTERNAL",
lifecycleState: "online",
connectedAt: timestamp,
lastSeenAt: timestamp,
disconnectedAt: null,
closeReasonCode: null,
frameCount: 1842,
byteCount: 734208,
});
configurationStates.set(deviceRef, {
projectRef,
deviceRef,
deviceName: "Пилотный B2",
desiredConfigurationRevisionRef: null,
appliedConfigurationRevisionRef: null,
appliedAt: null,
updatedAt: timestamp,
});
}
const previewTransitions = Object.freeze({
adapterPackage: Object.freeze({
active: Object.freeze(["active", "retired"]),
@@ -220,6 +220,24 @@ test("local preview is empty and creates resources only through canonical comman
assert.deepEqual(workspace.devices, []);
});
test("explicit B2 preview fixture is isolated from the empty canonical preview", async () => {
const client = createLocalPreviewDeviceCore({ fixture: "arusnavi-b2" });
const projects = await client.listProjects(actor);
assert.equal(projects.length, 1);
assert.equal(projects[0].counts.devices, 1);
const workspace = await client.getWorkspace(actor, projects[0].projectRef);
assert.equal(workspace.devices[0].modelProfileRef, "arusnavi.b2.internal.v1");
assert.equal(workspace.devices[0].identifier.masked, "***********0001");
assert.equal(workspace.sessions[0].lifecycleState, "online");
assert.equal(workspace.policies.commandTransport, "disabled");
assert.equal(JSON.stringify(workspace).includes("123456789012345"), false);
assert.throws(
() => createLocalPreviewDeviceCore({ fixture: "unknown" }),
/device_manager_preview_fixture_invalid/,
);
});
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
@@ -140,7 +140,9 @@ export async function createConfiguredDeviceManagerServer({ env = process.env }
if (String(env.NODE_ENV || "").toLowerCase() === "production") {
throw new Error("device_manager_local_preview_forbidden");
}
coreClient = createLocalPreviewDeviceCore();
coreClient = createLocalPreviewDeviceCore({
fixture: String(env.NODEDC_DEVICE_MANAGER_PREVIEW_FIXTURE || "").trim() || null,
});
} else {
const tokenFile = String(env.NODEDC_DEVICE_CORE_TOKEN_FILE || "").trim();
if (!tokenFile) throw new Error("device_core_token_file_required");
@@ -0,0 +1,329 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
GlassSurface,
Icon,
IconButton,
StatusBadge,
} from "@nodedc/ui-react";
import {
accessLabel,
getDeviceProfileCatalog,
type DeviceFieldAccess,
type DeviceProfileField,
} from "./deviceProfileCatalog";
import type {
DeviceView,
ProjectWorkspace,
SessionView,
} from "./types";
export function DeviceInventoryView({
workspace,
canEnroll,
onCreateEnrollment,
onPoll,
onError,
}: {
workspace: ProjectWorkspace;
canEnroll: boolean;
onCreateEnrollment: () => void;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [selectedDeviceRef, setSelectedDeviceRef] = useState<string | null>(null);
const selectedDevice = workspace.devices.find(
(device) => device.deviceRef === selectedDeviceRef,
) ?? null;
const activeRouteAvailable = workspace.routes.some(
(route) => route.lifecycleState === "active",
);
useEffect(() => {
setSelectedDeviceRef(null);
}, [workspace.project.projectRef]);
useEffect(() => {
if (selectedDeviceRef && !selectedDevice) setSelectedDeviceRef(null);
}, [selectedDevice, selectedDeviceRef]);
useEffect(() => {
if (!selectedDeviceRef) return undefined;
const poll = () => {
if (document.visibilityState === "visible") onPoll().catch(onError);
};
const timer = window.setInterval(poll, 5_000);
return () => window.clearInterval(timer);
}, [onError, onPoll, selectedDeviceRef]);
if (selectedDevice) {
return (
<DeviceDetailView
device={selectedDevice}
workspace={workspace}
onBack={() => setSelectedDeviceRef(null)}
/>
);
}
return (
<div className="device-inventory">
<div className="device-manager-panel-toolbar device-inventory__toolbar">
<div>
<strong>Реестр устройств</strong>
<p>{workspace.devices.length} зарегистрировано в проекте</p>
</div>
<IconButton
label="Подключить устройство"
disabled={!canEnroll || !activeRouteAvailable}
onClick={onCreateEnrollment}
>
<Icon name="plus" size={18} />
</IconButton>
</div>
{!workspace.devices.length ? (
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
<Icon name="inbox" size={24} />
<h3>В проекте пока нет устройств</h3>
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому enrollment-процессу.</p>
</GlassSurface>
) : (
<div className="device-inventory-table" role="table" aria-label="Устройства проекта">
<div className="device-inventory-table__head" role="row">
<span role="columnheader">Устройство</span>
<span role="columnheader">Профиль</span>
<span role="columnheader">Идентификатор</span>
<span role="columnheader">Канал</span>
<span role="columnheader">Последний пакет</span>
<span aria-hidden="true" />
</div>
{workspace.devices.map((device) => {
const session = latestSession(workspace, device);
const online = session?.lifecycleState === "online" || device.session?.state === "online";
return (
<button
key={device.deviceRef}
type="button"
className="device-inventory-row"
role="row"
onClick={() => setSelectedDeviceRef(device.deviceRef)}
>
<span className="device-inventory-row__device" role="cell">
<span className="device-inventory-row__icon"><Icon name="apps" size={17} /></span>
<span><strong>{device.displayName}</strong><small>{device.deviceKey || "ключ не назначен"}</small></span>
</span>
<span role="cell">{profileLabel(workspace, device)}</span>
<span role="cell">{device.identifier?.masked || "не назначен"}</span>
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
<span role="cell">{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</span>
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
</button>
);
})}
</div>
)}
</div>
);
}
function DeviceDetailView({
device,
workspace,
onBack,
}: {
device: DeviceView;
workspace: ProjectWorkspace;
onBack: () => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const [activeSectionId, setActiveSectionId] = useState(catalog.sections[0]?.id ?? "passport");
const detailRef = useRef<HTMLDivElement>(null);
const profile = workspace.modelProfiles.find(
(item) => item.modelProfileRef === device.modelProfileRef,
) ?? null;
const session = latestSession(workspace, device);
const configurationState = workspace.configurationStates.find(
(item) => item.deviceRef === device.deviceRef,
) ?? null;
const context = useMemo(() => ({
device,
profile,
session,
configurationState,
reported: device.reported ?? {},
policies: {
...workspace.policies,
firmwareUpdate: "blocked",
},
}), [configurationState, device, profile, session, workspace.policies]);
const activeSection = catalog.sections.find(
(section) => section.id === activeSectionId,
) ?? catalog.sections[0];
useEffect(() => {
setActiveSectionId(catalog.sections[0]?.id ?? "passport");
}, [catalog.profileRef, device.deviceRef]);
useEffect(() => {
const panelBody = detailRef.current?.closest<HTMLElement>(".nodedc-application-panel__body");
if (panelBody) panelBody.scrollTop = 0;
}, [activeSectionId, device.deviceRef]);
if (!activeSection) return null;
return (
<div ref={detailRef} className="device-detail">
<div className="device-detail__header">
<IconButton label="Вернуться к списку устройств" onClick={onBack}>
<Icon name="chevron-left" size={18} />
</IconButton>
<div className="device-detail__identity">
<small>{catalog.vendor} · {catalog.model}</small>
<h2>{device.displayName}</h2>
<p>{device.identifier?.masked || "Идентификатор не назначен"} · {device.modelProfileRef}</p>
</div>
<StatusBadge tone={session?.lifecycleState === "online" ? "success" : "neutral"}>
{session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
</StatusBadge>
</div>
<div className="device-detail__legend" aria-label="Режимы доступа">
<AccessBadge access="read-only" />
<AccessBadge access="managed" />
<AccessBadge access="protected" />
</div>
<div className="device-detail__layout">
<nav className="device-detail__navigation" aria-label="Разделы устройства">
{catalog.sections.map((section) => (
<button
key={section.id}
type="button"
className="device-detail__navigation-item"
data-active={section.id === activeSection.id ? "true" : undefined}
aria-current={section.id === activeSection.id ? "page" : undefined}
onClick={() => setActiveSectionId(section.id)}
>
<span>{section.label}</span>
<small>{accessLabel(section.access)}</small>
</button>
))}
</nav>
<section className="device-detail-section">
<div className="device-detail-section__heading">
<div>
<span>{catalog.title}</span>
<h3>{activeSection.title}</h3>
<p>{activeSection.description}</p>
</div>
<AccessBadge access={activeSection.access} />
</div>
<AccessNotice
access={activeSection.access}
commandTransport={workspace.policies.commandTransport}
/>
<div className="device-detail-fields">
{activeSection.fields.map((item) => {
const access = item.access ?? activeSection.access;
const value = readPath(context, item.path);
return (
<div key={item.key} className="device-detail-field" data-access={access}>
<div className="device-detail-field__label">
<span>{item.label}</span>
{access !== activeSection.access ? <AccessBadge access={access} compact /> : null}
</div>
<strong>{formatFieldValue(value, item)}</strong>
{item.description ? <small>{item.description}</small> : null}
</div>
);
})}
</div>
<div className="device-detail-section__state">
<span>Desired</span>
<strong>{configurationState?.desiredConfigurationRevisionRef || "Не задано"}</strong>
<span>Applied</span>
<strong>{configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}</strong>
</div>
</section>
</div>
</div>
);
}
function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
return <StatusBadge className={compact ? "device-access-badge--compact" : undefined} tone={tone}>{accessLabel(access)}</StatusBadge>;
}
function AccessNotice({
access,
commandTransport,
}: {
access: DeviceFieldAccess;
commandTransport: ProjectWorkspace["policies"]["commandTransport"];
}) {
if (access === "read-only") {
return <div className="device-detail-notice" data-access="read-only"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></div>;
}
if (access === "protected") {
return <div className="device-detail-notice" data-access="protected"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></div>;
}
return <div className="device-detail-notice" data-access="managed"><Icon name="settings" size={15} /><span>{commandTransport === "enabled" ? "Настройка управляется через command ledger." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></div>;
}
function latestSession(workspace: ProjectWorkspace, device: DeviceView): SessionView | null {
const sessions = workspace.sessions.filter((item) => item.deviceRef === device.deviceRef);
return sessions.sort((left, right) => {
if (left.lifecycleState === "online" && right.lifecycleState !== "online") return -1;
if (right.lifecycleState === "online" && left.lifecycleState !== "online") return 1;
return String(right.lastSeenAt || right.connectedAt || "").localeCompare(String(left.lastSeenAt || left.connectedAt || ""));
})[0] ?? null;
}
function profileLabel(workspace: ProjectWorkspace, device: DeviceView) {
const profile = workspace.modelProfiles.find(
(item) => item.modelProfileRef === device.modelProfileRef,
);
return profile ? `${profile.vendor} ${profile.model}` : device.modelProfileRef;
}
function readPath(input: unknown, path: string): unknown {
return path.split(".").reduce<unknown>((value, key) => {
if (!value || typeof value !== "object") return undefined;
return (value as Record<string, unknown>)[key];
}, input);
}
function formatFieldValue(value: unknown, item: DeviceProfileField) {
if (value === undefined || value === null || value === "") return "Нет данных";
if (item.sensitive) return "Задано · значение скрыто";
if (item.valueKind === "date") return formatDate(String(value));
if (item.valueKind === "boolean" || typeof value === "boolean") return value ? "Включено" : "Выключено";
if (value === "blocked") return "Запрещено";
if (Array.isArray(value)) return value.length ? value.join(", ") : "Нет данных";
if (typeof value === "object") return JSON.stringify(value);
return `${String(value)}${item.unit ? ` ${item.unit}` : ""}`;
}
function formatDate(value: string | null | undefined) {
if (!value) return "Нет данных";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
export const __deviceInventoryTestables = {
formatFieldValue,
readPath,
} as const;
+16 -10
View File
@@ -41,6 +41,7 @@ import {
DeviceControlView,
type ControlViewId,
} from "./DeviceControlViews";
import { DeviceInventoryView } from "./DeviceInventoryView";
type ViewId =
| "overview"
@@ -165,6 +166,12 @@ export function DeviceManagerApp() {
await refreshProjects();
};
const pollWorkspace = async () => {
if (!activeProjectRef) return;
const next = await loadWorkspace(activeProjectRef);
setWorkspace(next);
};
if (loading || !session) {
return <div className="device-manager-boot">Подключаем Device Core</div>;
}
@@ -286,6 +293,7 @@ export function DeviceManagerApp() {
canClaim={canClaim}
session={session}
onRefresh={refreshWorkspace}
onPoll={pollWorkspace}
onError={(reason) => setError(errorText(reason))}
onCreateCollection={() => setCollectionDialogOpen(true)}
onCreateEnrollment={() => setEnrollmentDialogOpen(true)}
@@ -458,7 +466,7 @@ function Metric({ label, value, detail, tone = "neutral" }: { label: string; val
);
}
function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onError, onCreateCollection, onCreateEnrollment, onClaim }: {
function ProjectView({ view, workspace, canManageCollections, canEnroll, canClaim, session, onRefresh, onPoll, onError, onCreateCollection, onCreateEnrollment, onClaim }: {
view: ViewId;
workspace: ProjectWorkspace | null;
canManageCollections: boolean;
@@ -466,6 +474,7 @@ function ProjectView({ view, workspace, canManageCollections, canEnroll, canClai
canClaim: boolean;
session: DeviceManagerSession;
onRefresh: () => Promise<void>;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
onCreateCollection: () => void;
onCreateEnrollment: () => void;
@@ -482,15 +491,12 @@ function ProjectView({ view, workspace, canManageCollections, canEnroll, canClai
/>;
}
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",
}))}
<DeviceInventoryView
workspace={workspace}
canEnroll={canEnroll}
onCreateEnrollment={onCreateEnrollment}
onPoll={onPoll}
onError={onError}
/>
);
if (view === "discovery") return (
@@ -0,0 +1,465 @@
export type DeviceFieldAccess = "read-only" | "managed" | "protected";
export type DeviceFieldValueKind = "text" | "number" | "boolean" | "date";
export interface DeviceProfileField {
key: string;
label: string;
path: string;
access?: DeviceFieldAccess;
valueKind?: DeviceFieldValueKind;
unit?: string;
description?: string;
sensitive?: boolean;
}
export interface DeviceProfileSection {
id: string;
label: string;
title: string;
description: string;
access: DeviceFieldAccess;
fields: DeviceProfileField[];
}
export interface DeviceProfileCatalog {
profileRef: string;
vendor: string;
model: string;
title: string;
sections: DeviceProfileSection[];
}
const field = (
key: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path"> = {},
): DeviceProfileField => ({ key, label, path, ...options });
const managed = (
key: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
) => field(key, label, path, { ...options, access: "managed" });
const protectedField = (
key: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
) => field(key, label, path, { ...options, access: "protected" });
const serverFields = (slot: number) => [
managed(`server-${slot}-host`, `Сервер ${slot}: DNS / IP`, `reported.configuration.monitoring.servers.${slot - 1}.host`),
managed(`server-${slot}-port`, `Сервер ${slot}: порт`, `reported.configuration.monitoring.servers.${slot - 1}.port`, { valueKind: "number" }),
managed(`server-${slot}-protocol`, `Сервер ${slot}: протокол`, `reported.configuration.monitoring.servers.${slot - 1}.protocol`),
managed(`server-${slot}-identity`, `Сервер ${slot}: ID (SN)`, `reported.configuration.monitoring.servers.${slot - 1}.identity`),
managed(`server-${slot}-password`, `Сервер ${slot}: пароль`, `reported.configuration.monitoring.servers.${slot - 1}.password`, { sensitive: true }),
];
const managedIndexedFields = (
count: number,
prefix: string,
label: string,
path: string,
options: Omit<DeviceProfileField, "key" | "label" | "path" | "access"> = {},
) => Array.from({ length: count }, (_, index) => managed(
`${prefix}-${index + 1}`,
`${label} ${index + 1}`,
`${path}.${index}`,
options,
));
const phoneFields = Array.from({ length: 5 }, (_, index) => [
managed(`phone-${index + 1}-number`, `Телефон ${index + 1}: номер`, `reported.configuration.phones.${index}.number`, { sensitive: true }),
managed(`phone-${index + 1}-mode`, `Телефон ${index + 1}: режим`, `reported.configuration.phones.${index}.mode`),
]).flat();
const simFields = (slot: number) => [
managed(`sim-${slot}-gprs`, `SIM ${slot}: передача данных`, `reported.configuration.simCards.${slot - 1}.gprsEnabled`, { valueKind: "boolean" }),
managed(`sim-${slot}-apn`, `SIM ${slot}: APN оператора`, `reported.configuration.simCards.${slot - 1}.apn`),
managed(`sim-${slot}-login`, `SIM ${slot}: логин APN`, `reported.configuration.simCards.${slot - 1}.login`, { sensitive: true }),
managed(`sim-${slot}-password`, `SIM ${slot}: пароль APN`, `reported.configuration.simCards.${slot - 1}.password`, { sensitive: true }),
managed(`sim-${slot}-roaming`, `SIM ${slot}: роуминг`, `reported.configuration.simCards.${slot - 1}.roamingEnabled`, { valueKind: "boolean" }),
managed(`sim-${slot}-operator`, `SIM ${slot}: приоритетный оператор`, `reported.configuration.simCards.${slot - 1}.preferredOperatorCode`),
managed(`sim-${slot}-pin`, `SIM ${slot}: PIN`, `reported.configuration.simCards.${slot - 1}.pin`, { sensitive: true }),
managed(`sim-${slot}-ussd`, `SIM ${slot}: USSD запроса баланса`, `reported.configuration.simCards.${slot - 1}.balanceUssd`, { sensitive: true }),
managed(`sim-${slot}-poll`, `SIM ${slot}: период запроса баланса`, `reported.configuration.simCards.${slot - 1}.balancePollHours`, { valueKind: "number", unit: "ч" }),
];
const motionEventFields = ["acceleration", "braking", "cornering", "vertical"].flatMap((event) => {
const labels: Record<string, string> = {
acceleration: "Разгон",
braking: "Торможение",
cornering: "Угловое ускорение",
vertical: "Вертикальное ускорение",
};
return Array.from({ length: 3 }, (_, level) => [
managed(`${event}-${level + 1}-threshold`, `${labels[event]} ${level + 1}: порог`, `reported.configuration.drivingStyle.${event}.${level}.thresholdMg`, { valueKind: "number", unit: "mg" }),
managed(`${event}-${level + 1}-duration`, `${labels[event]} ${level + 1}: длительность превышения`, `reported.configuration.drivingStyle.${event}.${level}.durationMs`, { valueKind: "number", unit: "мс" }),
managed(`${event}-${level + 1}-reset`, `${labels[event]} ${level + 1}: задержка сброса`, `reported.configuration.drivingStyle.${event}.${level}.resetDelayMs`, { valueKind: "number", unit: "мс" }),
]).flat();
}).flat();
const violationFields = ["speed", "rpm"].flatMap((kind) => Array.from({ length: 4 }, (_, level) => [
managed(`${kind}-${level + 1}-threshold`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог`, `reported.configuration.drivingStyle.violations.${kind}.${level}.threshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
managed(`${kind}-${level + 1}-duration`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: минимальное время`, `reported.configuration.drivingStyle.violations.${kind}.${level}.minimumDurationSeconds`, { valueKind: "number", unit: "с" }),
managed(`${kind}-${level + 1}-reset`, `${kind === "speed" ? "Скорость" : "Обороты"} ${level + 1}: порог сброса`, `reported.configuration.drivingStyle.violations.${kind}.${level}.resetThreshold`, { valueKind: "number", unit: kind === "speed" ? "км/ч" : "об/мин" }),
]).flat()).flat();
const modbusRegisterFields = Array.from({ length: 10 }, (_, index) => [
managed(`modbus-register-${index + 1}`, `Регистр ${index + 1}: номер`, `reported.configuration.modbus.registers.${index}.number`, { valueKind: "number" }),
managed(`modbus-register-${index + 1}-pair`, `Регистр ${index + 1}: читать два регистра`, `reported.configuration.modbus.registers.${index}.readPair`, { valueKind: "boolean" }),
]).flat();
const bleFields = Array.from({ length: 10 }, (_, index) => [
managed(`ble-${index + 1}-mac`, `BLE датчик ${index + 1}: MAC`, `reported.configuration.bluetooth.sensors.${index}.mac`),
managed(`ble-${index + 1}-integration`, `BLE датчик ${index + 1}: интеграция`, `reported.configuration.bluetooth.sensors.${index}.integrationExpression`),
]).flat();
export const ARUSNAVI_B2_CATALOG: DeviceProfileCatalog = {
profileRef: "arusnavi.b2.internal.v1",
vendor: "ARUSNAVI",
model: "B2",
title: "ARUSNAVI B2",
sections: [
{
id: "passport",
label: "Паспорт",
title: "Паспорт и состояние устройства",
description: "Реестровая идентичность, профиль модели и текущее состояние канала. Исходный идентификатор показывается только в проекции, разрешённой Device Core.",
access: "read-only",
fields: [
field("display-name", "Название", "device.displayName"),
field("device-key", "Ключ устройства", "device.deviceKey"),
field("device-ref", "Device Core ref", "device.deviceRef"),
field("vendor", "Производитель", "profile.vendor"),
field("model", "Модель", "profile.model"),
field("device-type", "Тип", "profile.deviceType"),
field("profile", "Профиль модели", "device.modelProfileRef"),
field("identifier-kind", "Тип идентификатора", "device.identifier.kind"),
field("identifier", "Идентификатор", "device.identifier.masked"),
field("iccid-1", "ICCID 1", "reported.identity.iccid1"),
field("iccid-2", "ICCID 2", "reported.identity.iccid2"),
field("lifecycle", "Состояние реестра", "device.lifecycleState"),
field("created", "Зарегистрирован", "device.createdAt", { valueKind: "date" }),
field("updated", "Обновлён", "device.updatedAt", { valueKind: "date" }),
field("reported-at", "Снимок устройства получен", "reported.observedAt", { valueKind: "date" }),
managed("asset-model", "Модель актива", "reported.metadata.model"),
managed("registration", "Регистрационный номер", "reported.metadata.registrationNumber"),
managed("object", "Объект", "reported.metadata.object"),
managed("description", "Описание", "reported.metadata.description"),
managed("sim-label-1", "Метка SIM 1", "reported.metadata.simLabel1"),
managed("sim-label-2", "Метка SIM 2", "reported.metadata.simLabel2"),
],
},
{
id: "live",
label: "Онлайн",
title: "Живой канал и телеметрия",
description: "Значения обновляются из последней gateway-сессии и безопасного снимка телеметрии. Интерфейс опрашивает Device Core, пока открыта карточка.",
access: "read-only",
fields: [
field("session-state", "Состояние соединения", "session.lifecycleState"),
field("session-route", "Маршрут", "session.routeName"),
field("session-connected", "Подключён", "session.connectedAt", { valueKind: "date" }),
field("session-last-seen", "Последний пакет", "session.lastSeenAt", { valueKind: "date" }),
field("session-frames", "Принято пакетов", "session.frameCount", { valueKind: "number" }),
field("session-bytes", "Принято данных", "session.byteCount", { valueKind: "number", unit: "байт" }),
field("latitude", "Широта", "reported.telemetry.navigation.latitude"),
field("longitude", "Долгота", "reported.telemetry.navigation.longitude"),
field("speed", "Скорость", "reported.telemetry.navigation.speedKph", { valueKind: "number", unit: "км/ч" }),
field("altitude", "Высота", "reported.telemetry.navigation.altitudeMeters", { valueKind: "number", unit: "м" }),
field("satellites", "Спутники", "reported.telemetry.navigation.satellites", { valueKind: "number" }),
field("course", "Курс", "reported.telemetry.navigation.courseDegrees", { valueKind: "number", unit: "°" }),
field("hdop", "HDOP", "reported.telemetry.navigation.hdop"),
field("gsm-signal", "Уровень GSM", "reported.telemetry.gsm.signal"),
field("gsm-operator", "Оператор", "reported.telemetry.gsm.operator"),
field("gsm-lac", "LAC", "reported.telemetry.gsm.lac"),
field("gsm-cid", "CID", "reported.telemetry.gsm.cid"),
field("external-voltage", "Внешнее напряжение", "reported.telemetry.system.externalVoltageMv", { valueKind: "number", unit: "мВ" }),
field("internal-voltage", "Внутреннее напряжение", "reported.telemetry.system.internalVoltageMv", { valueKind: "number", unit: "мВ" }),
field("errors", "Ошибки и статусы", "reported.telemetry.system.status"),
field("inputs", "Входы и выходы", "reported.telemetry.system.io"),
field("modules", "Статусы модулей", "reported.telemetry.system.modules"),
field("engine-hours", "Моточасы", "reported.telemetry.can.engineHours"),
field("odometer", "Пробег", "reported.telemetry.can.odometer"),
field("fuel-total", "Полный расход топлива", "reported.telemetry.can.fuelTotal"),
field("fuel-level", "Уровень топлива", "reported.telemetry.can.fuelLevel"),
field("rpm", "Обороты двигателя", "reported.telemetry.can.rpm"),
field("engine-temp", "Температура двигателя", "reported.telemetry.can.engineTemperature"),
field("vehicle-speed", "Скорость по CAN", "reported.telemetry.can.vehicleSpeed"),
field("axle-pressure", "Давление на оси", "reported.telemetry.can.axlePressure"),
field("crash", "Контроллер аварии", "reported.telemetry.can.crashController"),
field("instant-fuel", "Моментальный расход", "reported.telemetry.can.instantFuel"),
field("adblue", "Уровень AdBlue", "reported.telemetry.can.adBlueLevel"),
],
},
{
id: "firmware",
label: "Прошивка",
title: "Версия программного обеспечения",
description: "Версию и доступность обновления показываем, но запуск обновления для пилотного B2 запрещён. Этот запрет не снимается включением обычного командного канала.",
access: "protected",
fields: [
field("firmware-current", "Текущая версия", "reported.firmware.currentVersion"),
field("firmware-applied", "Версия применена", "reported.firmware.appliedAt", { valueKind: "date" }),
field("firmware-available", "Доступная версия", "reported.firmware.availableVersion"),
field("firmware-description", "Описание версии", "reported.firmware.description"),
protectedField("firmware-action", "Обновление прошивки", "policies.firmwareUpdate", { description: "Заблокировано для пилотного устройства" }),
],
},
{
id: "templates",
label: "Шаблоны",
title: "Шаблоны настроек",
description: "Шаблон хранит именованный снимок конфигурации модели. Применение должно создавать новую desired-ревизию, а не менять устройство в обход command ledger.",
access: "managed",
fields: [
field("template-current", "Применённый шаблон", "reported.configurationTemplate.name"),
field("template-applied", "Шаблон применён", "reported.configurationTemplate.appliedAt", { valueKind: "date" }),
managed("template-select", "Выбранный шаблон", "reported.configurationTemplate.selected"),
managed("template-name", "Название нового шаблона", "reported.configurationTemplate.draft.name"),
managed("template-description", "Описание нового шаблона", "reported.configurationTemplate.draft.description"),
],
},
{
id: "monitoring",
label: "Серверы",
title: "Серверы мониторинга",
description: "B2 поддерживает четыре серверных слота. Существующий Gelios сохраняется параллельно; новый маршрут не должен его перетирать.",
access: "managed",
fields: [1, 2, 3, 4].flatMap(serverFields),
},
{
id: "transmission",
label: "Передача",
title: "Набор передаваемых данных",
description: "Флаги определяют состав телеметрии, которую формирует устройство.",
access: "managed",
fields: [
managed("tx-nav-position", "Навигация: широта и долгота", "reported.configuration.transmission.navigation.position", { valueKind: "boolean" }),
managed("tx-nav-motion", "Навигация: скорость, высота, спутники и курс", "reported.configuration.transmission.navigation.motion", { valueKind: "boolean" }),
managed("tx-nav-hdop", "Навигация: HDOP", "reported.configuration.transmission.navigation.hdop", { valueKind: "boolean" }),
managed("tx-gsm-operator", "GSM: сигнал и оператор", "reported.configuration.transmission.gsm.operator", { valueKind: "boolean" }),
managed("tx-gsm-cell", "GSM: LAC и CID", "reported.configuration.transmission.gsm.cell", { valueKind: "boolean" }),
managed("tx-system-status", "Системные: ошибки и статусы", "reported.configuration.transmission.system.status", { valueKind: "boolean" }),
managed("tx-system-io", "Системные: входы, выходы и модули", "reported.configuration.transmission.system.io", { valueKind: "boolean" }),
managed("tx-system-voltage", "Системные: напряжения", "reported.configuration.transmission.system.voltage", { valueKind: "boolean" }),
...["statuses", "engineHours", "odometer", "fuelTotal", "fuelLevel", "rpm", "engineTemperature", "vehicleSpeed", "axlePressure", "crashController", "instantFuel", "adBlueLevel"].map((key) => managed(`tx-can-${key}`, `CAN: ${({ statuses: "статусы работы", engineHours: "моточасы", odometer: "пробег", fuelTotal: "полный расход топлива", fuelLevel: "уровень топлива", rpm: "обороты двигателя", engineTemperature: "температура двигателя", vehicleSpeed: "скорость", axlePressure: "давление на оси", crashController: "контроллер аварии", instantFuel: "моментальный расход", adBlueLevel: "уровень AdBlue" } as Record<string, string>)[key]}`, `reported.configuration.transmission.can.${key}`, { valueKind: "boolean" })),
],
},
{
id: "trajectory",
label: "Траектория",
title: "Отрисовка траектории и датчик движения",
description: "Обычные и роуминговые интервалы, заморозка координат и параметры встроенного датчика движения.",
access: "managed",
fields: [
...["normal", "roaming"].flatMap((mode) => {
const label = mode === "normal" ? "Основной режим" : "Роуминг";
return [
managed(`${mode}-course`, `${label}: изменение курса`, `reported.configuration.trajectory.${mode}.courseDeltaDegrees`, { valueKind: "number", unit: "°" }),
managed(`${mode}-speed`, `${label}: изменение скорости`, `reported.configuration.trajectory.${mode}.speedDeltaKph`, { valueKind: "number", unit: "км/ч" }),
managed(`${mode}-distance`, `${label}: расстояние между точками`, `reported.configuration.trajectory.${mode}.distanceMeters`, { valueKind: "number", unit: "м" }),
managed(`${mode}-parking`, `${label}: интервал на стоянке`, `reported.configuration.trajectory.${mode}.parkingIntervalSeconds`, { valueKind: "number", unit: "с" }),
];
}),
managed("freeze-low-speed", "Заморозка координат при скорости ниже 2 км/ч", "reported.configuration.trajectory.freeze.lowSpeed", { valueKind: "boolean" }),
managed("freeze-motion", "Заморозка по датчику движения", "reported.configuration.trajectory.freeze.motionSensor", { valueKind: "boolean" }),
managed("freeze-ignition", "Заморозка по зажиганию", "reported.configuration.trajectory.freeze.ignition", { valueKind: "boolean" }),
managed("freeze-quiet", "Тихоходная техника", "reported.configuration.trajectory.freeze.lowSpeedVehicle", { valueKind: "boolean" }),
managed("motion-sensitivity", "Чувствительность датчика движения", "reported.configuration.motionSensor.sensitivity", { valueKind: "number" }),
managed("motion-delay", "Задержка срабатывания", "reported.configuration.motionSensor.delaySeconds", { valueKind: "number", unit: "с" }),
managed("motion-impact", "Порог удара", "reported.configuration.motionSensor.impact", { valueKind: "number" }),
managed("motion-tilt", "Порог наклона", "reported.configuration.motionSensor.tilt", { valueKind: "number" }),
],
},
{
id: "io",
label: "Входы / выходы",
title: "Входы и выходы",
description: "Режимы PIN0–PIN7 и пороги. Непосредственное переключение выходов относится к защищённым командам.",
access: "managed",
fields: [
...managedIndexedFields(8, "pin-mode", "Режим PIN", "reported.configuration.io.pinModes"),
managed("speed-coefficient", "Коэффициент датчика скорости", "reported.configuration.io.speedSensorCoefficient", { valueKind: "number" }),
managed("virtual-ignition", "Порог виртуального зажигания", "reported.configuration.io.virtualIgnitionThresholdMv", { valueKind: "number", unit: "мВ" }),
managed("analog-pin-2", "Порог аналогового входа PIN2", "reported.configuration.io.analogThresholds.pin2Mv", { valueKind: "number", unit: "мВ" }),
managed("analog-pin-3", "Порог аналогового входа PIN3", "reported.configuration.io.analogThresholds.pin3Mv", { valueKind: "number", unit: "мВ" }),
protectedField("output-4", "Команда выхода PIN4", "reported.operations.outputs.pin4"),
protectedField("output-5", "Команда выхода PIN5", "reported.operations.outputs.pin5"),
protectedField("output-6", "Команда выхода PIN6", "reported.operations.outputs.pin6"),
],
},
{
id: "ports",
label: "Порты",
title: "Цифровые порты и датчики",
description: "RS232, RS485, CAN, WiFi, фотоснимки, 1‑Wire и фильтрация датчиков.",
access: "managed",
fields: [
managed("rs232", "RS232", "reported.configuration.ports.rs232.mode"),
managed("rs485", "RS485", "reported.configuration.ports.rs485.mode"),
managed("can-program", "Номер программы CAN", "reported.configuration.ports.can.program", { valueKind: "number" }),
managed("can-internal", "Активировать внутренний CAN", "reported.configuration.ports.can.internalEnabled", { valueKind: "boolean" }),
managed("can-seatbelt", "Контролировать ремень по CAN", "reported.configuration.ports.can.seatbelt", { valueKind: "boolean" }),
managed("can-headlight", "Контролировать ближний свет по CAN", "reported.configuration.ports.can.headlight", { valueKind: "boolean" }),
managed("wifi-ssid", "WiFi: имя сети", "reported.configuration.ports.wifi.ssid"),
managed("wifi-password", "WiFi: пароль", "reported.configuration.ports.wifi.password", { sensitive: true }),
managed("photo-interval", "Интервал фотоснимков", "reported.configuration.ports.camera.intervalMinutes", { valueKind: "number", unit: "мин" }),
managed("photo-resolution", "Разрешение фотоснимков", "reported.configuration.ports.camera.resolution"),
managed("one-wire-auto", "Сохранять новые термодатчики", "reported.configuration.ports.oneWire.autoDiscover", { valueKind: "boolean" }),
...managedIndexedFields(10, "one-wire", "Адрес термодатчика", "reported.configuration.ports.oneWire.sensorAddresses"),
managed("median-filter", "Медианный фильтр датчиков", "reported.configuration.ports.sensorFilter.medianEnabled", { valueKind: "boolean" }),
...managedIndexedFields(4, "lls-filter", "Степень фильтрации LLS", "reported.configuration.ports.sensorFilter.lls", { valueKind: "number" }),
],
},
{
id: "modbus",
label: "Modbus",
title: "Параметры Modbus",
description: "Последовательный порт, сетевые адреса и до десяти читаемых регистров.",
access: "managed",
fields: [
managed("modbus-baud", "Скорость обмена", "reported.configuration.modbus.baudRate", { valueKind: "number" }),
managed("modbus-poll", "Таймер опроса", "reported.configuration.modbus.pollSeconds", { valueKind: "number", unit: "с" }),
managed("modbus-parity", "Проверка на чётность", "reported.configuration.modbus.parity"),
managed("modbus-stop", "Stop bits", "reported.configuration.modbus.stopBits"),
managed("modbus-address-a", "Сетевой адрес датчика для регистров 1–5", "reported.configuration.modbus.addresses.first", { valueKind: "number" }),
managed("modbus-address-b", "Сетевой адрес датчика для регистров 6–10", "reported.configuration.modbus.addresses.second", { valueKind: "number" }),
...modbusRegisterFields,
],
},
{
id: "bluetooth",
label: "Bluetooth",
title: "Bluetooth (BLE) датчики",
description: "Режим BLE-модуля, код сопряжения и десять датчиков с выражениями универсальной интеграции.",
access: "managed",
fields: [
managed("ble-mode", "Режим работы Bluetooth", "reported.configuration.bluetooth.mode"),
managed("ble-pairing", "Код сопряжения", "reported.configuration.bluetooth.pairingCode", { sensitive: true }),
...bleFields,
],
},
{
id: "driving-style",
label: "Стиль вождения",
title: "Стиль вождения",
description: "Пороговые профили акселерометра и превышений скорости/оборотов.",
access: "managed",
fields: [
...motionEventFields,
managed("accelerometer-transmit", "Передавать данные акселерометра", "reported.configuration.drivingStyle.transmitAccelerometer", { valueKind: "boolean" }),
managed("accelerometer-reset-events", "Передавать события сброса", "reported.configuration.drivingStyle.transmitResetEvents", { valueKind: "boolean" }),
managed("accelerometer-bitmask", "Передавать состояния сработок", "reported.configuration.drivingStyle.transmitTriggerMask", { valueKind: "boolean" }),
managed("accelerometer-average", "Глубина усреднения акселерометра", "reported.configuration.drivingStyle.averagingDepth", { valueKind: "number" }),
...violationFields,
],
},
{
id: "phones",
label: "Телефоны",
title: "Разрешённые телефоны",
description: "До пяти номеров и индивидуальный режим доступа для SMS-управления.",
access: "managed",
fields: phoneFields,
},
{
id: "sim",
label: "SIM-карты",
title: "SIM-карты и мобильная сеть",
description: "Параметры двух SIM-профилей. Пароли, PIN и USSD не возвращаются в открытом виде.",
access: "managed",
fields: [...simFields(1), ...simFields(2)],
},
{
id: "navigation",
label: "Навигация",
title: "Навигация и фильтрация координат",
description: "Источники координат, спутниковые группировки, внешний локатор и фильтры качества.",
access: "managed",
fields: [
managed("nav-satellite", "Спутниковая навигация", "reported.configuration.navigation.sources.satellite", { valueKind: "boolean" }),
managed("nav-wifi", "WiFi локатор", "reported.configuration.navigation.sources.wifi", { valueKind: "boolean" }),
managed("nav-lbs", "LBS локатор", "reported.configuration.navigation.sources.lbs", { valueKind: "boolean" }),
managed("nav-tag", "Навигационная метка", "reported.configuration.navigation.sources.tag", { valueKind: "boolean" }),
...["gps", "glonass", "galileo", "beidou"].map((key) => managed(`nav-${key}`, key.toUpperCase(), `reported.configuration.navigation.constellations.${key}`, { valueKind: "boolean" })),
managed("locator-url", "URL локатора", "reported.configuration.navigation.locator.url", { sensitive: true }),
managed("locator-moving", "Интервал локатора в движении", "reported.configuration.navigation.locator.movingIntervalSeconds", { valueKind: "number", unit: "с" }),
managed("locator-parked", "Интервал локатора на стоянке", "reported.configuration.navigation.locator.parkedIntervalSeconds", { valueKind: "number", unit: "с" }),
managed("filter-satellites", "Минимальное число спутников", "reported.configuration.navigation.filter.minimumSatellites", { valueKind: "number" }),
managed("filter-hdop", "Максимальный HDOP × 10", "reported.configuration.navigation.filter.maximumHdopTimesTen", { valueKind: "number" }),
managed("filter-altitude-min", "Минимальная высота", "reported.configuration.navigation.filter.minimumAltitudeMeters", { valueKind: "number", unit: "м" }),
managed("filter-altitude-max", "Максимальная высота", "reported.configuration.navigation.filter.maximumAltitudeMeters", { valueKind: "number", unit: "м" }),
managed("filter-speed-min", "Минимальная мгновенная скорость", "reported.configuration.navigation.filter.minimumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
managed("filter-speed-max", "Максимальная мгновенная скорость", "reported.configuration.navigation.filter.maximumInstantSpeedKph", { valueKind: "number", unit: "км/ч" }),
managed("filter-speed-average", "Максимальная средняя скорость", "reported.configuration.navigation.filter.maximumAverageSpeedKph", { valueKind: "number", unit: "км/ч" }),
managed("filter-time", "Максимальное время фильтрации", "reported.configuration.navigation.filter.maximumSeconds", { valueKind: "number", unit: "с" }),
],
},
{
id: "system",
label: "Системные",
title: "Системные параметры",
description: "Системные интервалы и энергосбережение. Секретные значения отображаются только как факт наличия.",
access: "managed",
fields: [
managed("sms-password", "Пароль устройства (SMS)", "reported.configuration.system.smsPassword", { sensitive: true }),
managed("web-check-hours", "Проверять WEB-конфигуратор каждые", "reported.configuration.system.webConfiguration.checkHours", { valueKind: "number", unit: "ч" }),
managed("web-check-start", "Проверять WEB-конфигуратор при старте", "reported.configuration.system.webConfiguration.onStart", { valueKind: "boolean" }),
managed("sleep-mode", "Режим сна", "reported.configuration.system.powerSaving.mode"),
managed("sleep-wake-interval", "Выходить на связь каждые", "reported.configuration.system.powerSaving.wakeIntervalMinutes", { valueKind: "number", unit: "мин" }),
managed("sleep-online", "Время пребывания на связи", "reported.configuration.system.powerSaving.onlineMinutes", { valueKind: "number", unit: "мин" }),
managed("sleep-motion", "Выходить из сна по датчику движения", "reported.configuration.system.powerSaving.wakeOnMotion", { valueKind: "boolean" }),
managed("sleep-input", "Выходить из сна по изменению входа", "reported.configuration.system.powerSaving.wakeOnInput", { valueKind: "boolean" }),
managed("battery-ignition", "Заряжать АКБ только при включённом зажигании", "reported.configuration.system.chargeBatteryOnIgnitionOnly", { valueKind: "boolean" }),
],
},
{
id: "diagnostics",
label: "Диагностика",
title: "Диагностика и операции",
description: "Доступные B2 операции отражены полностью, но выполняются только через подтверждённый двусторонний канал и отдельный command ledger.",
access: "protected",
fields: [
field("debug-last-session", "Последняя удалённая отладка", "reported.diagnostics.lastSessionAt", { valueKind: "date" }),
field("debug-output", "Результат удалённой отладки", "reported.diagnostics.output"),
protectedField("op-packet", "Запросить пакет телеметрии", "reported.operations.requestTelemetry"),
protectedField("op-info", "Запросить информацию", "reported.operations.requestInfo"),
protectedField("op-coordinates", "Запросить координаты", "reported.operations.requestCoordinates"),
protectedField("op-config", "Синхронизировать настройки", "reported.operations.syncConfiguration"),
protectedField("op-restart", "Перезапустить устройство", "reported.operations.restart"),
protectedField("op-clear", "Очистить память", "reported.operations.clearMemory"),
protectedField("op-firmware", "Обновить прошивку", "reported.operations.updateFirmware", { description: "Запрещено для пилотного B2" }),
],
},
],
};
const genericCatalog: DeviceProfileCatalog = {
profileRef: "generic.device.v1",
vendor: "NODE.DC",
model: "Generic device",
title: "Устройство",
sections: ARUSNAVI_B2_CATALOG.sections.filter((section) => ["passport", "live"].includes(section.id)),
};
const catalogs = new Map<string, DeviceProfileCatalog>([
[ARUSNAVI_B2_CATALOG.profileRef, ARUSNAVI_B2_CATALOG],
]);
export function getDeviceProfileCatalog(profileRef: string): DeviceProfileCatalog {
return catalogs.get(profileRef) ?? { ...genericCatalog, profileRef };
}
export function accessLabel(access: DeviceFieldAccess) {
return ({
"read-only": "Только чтение",
managed: "Управляемая настройка",
protected: "Защищённая операция",
})[access];
}
+393
View File
@@ -504,6 +504,366 @@ textarea {
font-size: 0.72rem;
}
.device-inventory {
display: grid;
gap: 1rem;
}
.device-inventory__toolbar > div:first-child {
display: grid;
gap: 0.2rem;
}
.device-inventory__toolbar strong {
font-size: 0.95rem;
}
.device-inventory__empty {
min-height: 280px;
}
.device-inventory-table {
overflow: hidden;
border: 1px solid var(--nodedc-glass-border);
border-radius: 20px;
background: color-mix(in srgb, var(--nodedc-glass-surface-bg) 86%, transparent);
}
.device-inventory-table__head,
.device-inventory-row {
display: grid;
grid-template-columns: minmax(180px, 1.45fr) minmax(130px, 1fr) minmax(140px, 1fr) minmax(90px, 0.7fr) minmax(150px, 0.9fr) 28px;
align-items: center;
gap: 0.8rem;
}
.device-inventory-table__head {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--nodedc-glass-border);
color: var(--nodedc-text-tertiary);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.device-inventory-row {
width: 100%;
border: 0;
border-bottom: 1px solid var(--nodedc-glass-border);
background: transparent;
color: inherit;
padding: 0.9rem 1rem;
font: inherit;
text-align: left;
cursor: pointer;
transition: background 160ms ease, transform 160ms ease;
}
.device-inventory-row:last-child {
border-bottom: 0;
}
.device-inventory-row:hover,
.device-inventory-row:focus-visible {
outline: none;
background: var(--nodedc-glass-control-bg);
}
.device-inventory-row:active {
transform: translateY(1px);
}
.device-inventory-row > span {
min-width: 0;
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-inventory-row__device {
display: flex;
align-items: center;
gap: 0.7rem;
}
.device-inventory-row__device > span:last-child {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.device-inventory-row__device strong {
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.82rem;
text-overflow: ellipsis;
}
.device-inventory-row__device small {
overflow: hidden;
color: var(--nodedc-text-tertiary);
font-size: 0.67rem;
text-overflow: ellipsis;
}
.device-inventory-row__icon {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
border-radius: 10px;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-accent, #b9ff37);
}
.device-inventory-row__open {
display: grid;
justify-items: end;
color: var(--nodedc-text-tertiary) !important;
}
.device-detail {
display: grid;
gap: 1rem;
}
.device-detail__header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--nodedc-glass-border);
}
.device-detail__identity {
display: grid;
min-width: 0;
gap: 0.12rem;
}
.device-detail__identity small {
color: var(--nodedc-accent, #b9ff37);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.device-detail__identity h2,
.device-detail__identity p {
margin: 0;
}
.device-detail__identity h2 {
overflow: hidden;
font-size: clamp(1.15rem, 2vw, 1.55rem);
text-overflow: ellipsis;
white-space: nowrap;
}
.device-detail__identity p {
overflow: hidden;
color: var(--nodedc-text-secondary);
font-size: 0.73rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-detail__legend {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.device-detail__layout {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
align-items: start;
gap: 1rem;
}
.device-detail__navigation {
display: grid;
overflow: hidden;
border: 1px solid var(--nodedc-glass-border);
border-radius: 16px;
background: color-mix(in srgb, var(--nodedc-glass-surface-bg) 82%, transparent);
}
.device-detail__navigation-item {
display: grid;
gap: 0.15rem;
border: 0;
border-bottom: 1px solid var(--nodedc-glass-border);
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0.68rem 0.8rem;
text-align: left;
cursor: pointer;
}
.device-detail__navigation-item:last-child {
border-bottom: 0;
}
.device-detail__navigation-item:hover,
.device-detail__navigation-item:focus-visible {
outline: none;
background: var(--nodedc-glass-control-bg);
}
.device-detail__navigation-item[data-active="true"] {
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-primary);
box-shadow: inset 3px 0 0 var(--nodedc-accent, #b9ff37);
}
.device-detail__navigation-item span {
font-size: 0.76rem;
font-weight: 700;
}
.device-detail__navigation-item small {
color: var(--nodedc-text-tertiary);
font-size: 0.58rem;
}
.device-detail-section {
display: grid;
min-width: 0;
gap: 0.9rem;
}
.device-detail-section__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.device-detail-section__heading > div {
display: grid;
gap: 0.28rem;
}
.device-detail-section__heading span:first-child {
color: var(--nodedc-text-tertiary);
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.device-detail-section__heading h3,
.device-detail-section__heading p {
margin: 0;
}
.device-detail-section__heading h3 {
font-size: 1.08rem;
}
.device-detail-section__heading p {
max-width: 760px;
color: var(--nodedc-text-secondary);
font-size: 0.75rem;
line-height: 1.55;
}
.device-detail-notice {
display: flex;
align-items: flex-start;
gap: 0.55rem;
border: 1px solid var(--nodedc-glass-border);
border-radius: 13px;
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
padding: 0.7rem 0.8rem;
font-size: 0.7rem;
line-height: 1.45;
}
.device-detail-notice[data-access="managed"] {
border-color: color-mix(in srgb, var(--nodedc-accent, #b9ff37) 32%, var(--nodedc-glass-border));
}
.device-detail-notice[data-access="protected"] {
border-color: color-mix(in srgb, #ffb347 38%, var(--nodedc-glass-border));
}
.device-detail-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.device-detail-field {
display: grid;
min-width: 0;
gap: 0.42rem;
border: 1px solid var(--nodedc-glass-border);
border-radius: 14px;
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 78%, transparent);
padding: 0.75rem 0.8rem;
}
.device-detail-field[data-access="read-only"] {
background: color-mix(in srgb, var(--nodedc-glass-control-bg) 58%, transparent);
}
.device-detail-field[data-access="protected"] {
border-color: color-mix(in srgb, #ffb347 26%, var(--nodedc-glass-border));
}
.device-detail-field__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
color: var(--nodedc-text-secondary);
font-size: 0.67rem;
}
.device-detail-field strong {
overflow-wrap: anywhere;
font-size: 0.8rem;
font-weight: 650;
line-height: 1.4;
}
.device-detail-field > small {
color: var(--nodedc-text-tertiary);
font-size: 0.62rem;
line-height: 1.45;
}
.device-access-badge--compact {
flex: 0 0 auto;
padding: 0.25rem 0.45rem !important;
font-size: 0.56rem !important;
}
.device-detail-section__state {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.35rem 0.7rem;
border-top: 1px solid var(--nodedc-glass-border);
padding-top: 0.85rem;
color: var(--nodedc-text-secondary);
font-size: 0.65rem;
}
.device-detail-section__state strong {
overflow-wrap: anywhere;
color: var(--nodedc-text-primary);
font-weight: 600;
}
.device-manager-form {
display: grid;
gap: 1rem;
@@ -570,4 +930,37 @@ textarea {
grid-column: 2;
justify-self: end;
}
.device-inventory-table__head {
display: none;
}
.device-inventory-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.device-inventory-row > span:not(.device-inventory-row__device):not(.device-inventory-row__open) {
display: none;
}
.device-detail__layout {
grid-template-columns: 1fr;
}
.device-detail__navigation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.device-detail__navigation-item:nth-child(odd) {
border-right: 1px solid var(--nodedc-glass-border);
}
.device-detail-fields {
grid-template-columns: 1fr;
}
.device-detail-section__heading {
align-items: stretch;
flex-direction: column;
}
}
+10
View File
@@ -53,6 +53,16 @@ export interface DeviceView {
lifecycleState: string;
identifier: { kind: string; masked: string } | null;
session: { state: string; lastSeenAt: string | null } | null;
reported?: {
observedAt?: string | null;
identity?: Record<string, unknown>;
metadata?: Record<string, unknown>;
firmware?: Record<string, unknown>;
configuration?: Record<string, unknown>;
telemetry?: Record<string, unknown>;
diagnostics?: Record<string, unknown>;
operations?: Record<string, unknown>;
} | null;
createdAt: string | null;
updatedAt: string | null;
}