import { useEffect, useMemo, useRef, useState } from "react";
import {
Button,
GlassSurface,
Icon,
IconButton,
Select,
SettingsCard,
StatusBadge,
Switch,
TextField,
} from "@nodedc/ui-react";
import {
createConfigurationRevision,
setDesiredConfiguration,
updateDevice,
} from "./api";
import {
accessLabel,
getDeviceProfileCatalog,
type DeviceFieldAccess,
type DeviceProfileField,
} from "./deviceProfileCatalog";
import type {
DeviceView,
ProjectWorkspace,
SessionView,
} from "./types";
export type DeviceInventoryDetailState = {
deviceRef: string;
sectionId: string;
editing: boolean;
};
export function DeviceDetailHeaderTools({
device,
detail,
canEdit,
onDetailChange,
}: {
device: DeviceView;
detail: DeviceInventoryDetailState;
canEdit: boolean;
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const activeSection = catalog.sections.find((section) => section.id === detail.sectionId)
?? catalog.sections[0];
return (
);
}
export function DeviceInventoryView({
workspace,
canClaim,
canConfigure,
canManageProject,
detail,
onClaim,
onPoll,
onError,
onDetailChange,
}: {
workspace: ProjectWorkspace;
canClaim: boolean;
canConfigure: boolean;
canManageProject: boolean;
detail: DeviceInventoryDetailState | null;
onClaim: (enrollment: ProjectWorkspace["enrollments"][number]) => void;
onPoll: () => Promise;
onError: (reason: unknown) => void;
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
}) {
const [statusFilter, setStatusFilter] = useState("all");
const [sortOrder, setSortOrder] = useState("activity");
const selectedDevice = workspace.devices.find(
(device) => device.deviceRef === detail?.deviceRef,
) ?? null;
const pendingEnrollments = workspace.enrollments.filter(
(enrollment) => enrollment.lifecycleState !== "claimed",
);
const deviceRows = useMemo(() => workspace.devices
.map((device) => {
const session = latestSession(workspace, device);
const online = session?.lifecycleState === "online" || device.session?.state === "online";
const lastSeenAt = session?.lastSeenAt || device.session?.lastSeenAt || device.updatedAt;
return { device, session, online, lastSeenAt };
})
.filter((row) => {
if (statusFilter === "active") return row.online;
if (statusFilter === "inactive") return !row.online;
return statusFilter !== "pending";
})
.sort((left, right) => {
if (sortOrder === "name") {
return left.device.displayName.localeCompare(right.device.displayName, "ru");
}
if (sortOrder === "activity" && left.online !== right.online) {
return left.online ? -1 : 1;
}
return String(right.lastSeenAt || "").localeCompare(String(left.lastSeenAt || ""));
}), [sortOrder, statusFilter, workspace]);
useEffect(() => {
if (detail?.deviceRef && !selectedDevice) onDetailChange(null);
}, [detail?.deviceRef, onDetailChange, selectedDevice]);
useEffect(() => {
if (!detail?.deviceRef) return undefined;
const poll = () => {
if (document.visibilityState === "visible") onPoll().catch(onError);
};
const timer = window.setInterval(poll, 5_000);
return () => window.clearInterval(timer);
}, [detail?.deviceRef, onError, onPoll]);
if (selectedDevice && detail) {
return (
onDetailChange(null)}
/>
);
}
return (
Реестр устройств
{workspace.devices.length} зарегистрировано · {pendingEnrollments.length} ожидают подключения
{statusFilter !== "pending" && !deviceRows.length ? (
{workspace.devices.length ? "Устройств с таким состоянием нет" : "В проекте пока нет устройств"}
Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому процессу подключения.
) : statusFilter !== "pending" ? (
Устройство
Профиль
IMEI
ID интеграционного устройства
Канал
Последний пакет
{deviceRows.map(({ device, session, online, lastSeenAt }) => {
return (
);
})}
) : null}
{(statusFilter === "all" || statusFilter === "pending") ? (
Ожидают подключения
Разрешённые идентификаторы и обнаруженные устройства.
{pendingEnrollments.length}
{pendingEnrollments.length ? pendingEnrollments.map((enrollment) => (
onClaim(enrollment)}>
Принять устройство
) : {enrollment.lifecycleState}}
>
После первого пакета устройство можно принять в реестр. Исходный идентификатор в интерфейсе не раскрывается.
)) : (
Нет ожидающих подключений.
)}
) : null}
);
}
function DeviceDetailView({
device,
detail,
workspace,
canConfigure,
canManageProject,
onDetailChange,
onSaved,
onError,
onBack,
}: {
device: DeviceView;
detail: DeviceInventoryDetailState;
workspace: ProjectWorkspace;
canConfigure: boolean;
canManageProject: boolean;
onDetailChange: (detail: DeviceInventoryDetailState | null) => void;
onSaved: () => Promise;
onError: (reason: unknown) => void;
onBack: () => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const detailRef = useRef(null);
const [draftValues, setDraftValues] = useState>({});
const [saving, setSaving] = useState(false);
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 identifierDisplayValue = deviceIdentifierDisplayValue(device);
const context = useMemo(() => ({
device: {
...device,
identifier: device.identifier ? {
...device.identifier,
displayValue: identifierDisplayValue,
} : null,
},
profile,
session,
configurationState,
reported: device.reported ?? {},
policies: {
...workspace.policies,
firmwareUpdate: "blocked",
},
}), [configurationState, device, identifierDisplayValue, profile, session, workspace.policies]);
const activeSection = catalog.sections.find(
(section) => section.id === detail.sectionId,
) ?? catalog.sections[0];
useEffect(() => {
if (!catalog.sections.some((section) => section.id === detail.sectionId)) {
onDetailChange({
...detail,
sectionId: catalog.sections[0]?.id ?? "passport",
});
}
}, [catalog.sections, detail, onDetailChange]);
useEffect(() => {
setDraftValues({});
}, [detail.editing, device.deviceRef]);
useEffect(() => {
const panelBody = detailRef.current?.closest(".nodedc-application-panel__body");
if (panelBody) panelBody.scrollTop = 0;
}, [detail.sectionId, device.deviceRef]);
if (!activeSection) return null;
const saveDeviceChanges = async () => {
if (!(canConfigure || canManageProject) || !Object.keys(draftValues).length) return;
setSaving(true);
try {
const displayNameDraft = draftValues["device.displayName"];
const integrationDeviceIdDraft = draftValues["device.integrationDeviceId"];
const nextDisplayName = typeof displayNameDraft === "string"
? displayNameDraft.trim()
: device.displayName;
const nextIntegrationDeviceId = typeof integrationDeviceIdDraft === "string"
? integrationDeviceIdDraft.trim() || null
: device.integrationDeviceId;
if (
canManageProject
&& nextDisplayName
&& (
nextDisplayName !== device.displayName
|| nextIntegrationDeviceId !== device.integrationDeviceId
)
) {
await updateDevice({
projectRef: workspace.project.projectRef,
deviceRef: device.deviceRef,
displayName: nextDisplayName,
integrationDeviceId: nextIntegrationDeviceId,
});
}
const configurationDrafts = Object.entries(draftValues).filter(([path]) =>
path.startsWith("reported.configuration."),
);
if (configurationDrafts.length) {
const nextConfiguration = cloneConfiguration(device.reported?.configuration);
for (const [path, value] of configurationDrafts) {
const field = catalog.sections.flatMap((section) => section.fields)
.find((item) => item.path === path);
if (!field || !isDeviceFieldEditable(field, field.access ?? activeSection.access, {
canConfigure,
canManageProject,
})) continue;
writePath(
nextConfiguration,
path.replace(/^reported\.configuration\./, ""),
normalizeDraftValue(value, field),
);
}
const created = await createConfigurationRevision({
projectRef: workspace.project.projectRef,
deviceRef: device.deviceRef,
configuration: nextConfiguration,
changeSummary: `Device Manager · ${activeSection.title}`,
});
await setDesiredConfiguration({
projectRef: workspace.project.projectRef,
deviceRef: device.deviceRef,
configurationRevisionRef: created.result.configurationRevision.configurationRevisionRef,
});
}
setDraftValues({});
onDetailChange({ ...detail, editing: false });
await onSaved();
} catch (reason) {
onError(reason);
} finally {
setSaving(false);
}
};
return (
{catalog.vendor} · {catalog.model}
{device.displayName}
{identifierDisplayValue || "Идентификатор не назначен"} · {device.modelProfileRef}
{session?.lifecycleState === "online" ? "Онлайн" : session?.lifecycleState || device.lifecycleState}
Состояние связи{session?.lifecycleState || device.session?.state || "Нет сессии"}
Маршрут{session?.routeName || "Не определён"}
Протокол{session?.protocol || profile?.protocol || "Нет данных"}
Последняя активность{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}
Пакеты{session?.frameCount ?? 0}
Подключено{formatDate(session?.connectedAt)}
{catalog.title}
{activeSection.title}
{activeSection.description}
{activeSection.fields.map((item) => {
const access = item.access ?? activeSection.access;
const editable = detail.editing
&& isDeviceFieldEditable(item, access, {
canConfigure,
canManageProject,
});
const value = Object.prototype.hasOwnProperty.call(draftValues, item.path)
? draftValues[item.path]
: readPath(context, item.path);
return (
{editable && item.valueKind === "boolean" ? (
setDraftValues((current) => ({ ...current, [item.path]: checked }))}
/>
) : editable ? (
setDraftValues((current) => ({ ...current, [item.path]: event.target.value }))}
/>
) : (
<>
{item.label}
{access !== activeSection.access ?
: null}
{formatFieldValue(value, item)}
{item.description ? {item.description} : null}
>
)}
);
})}
{detail.editing ? (
}
disabled={saving || !Object.keys(draftValues).length}
onClick={() => void saveDeviceChanges()}
>
{saving ? "Сохраняем…" : "Сохранить"}
) : null}
Desired
{configurationState?.desiredConfigurationRevisionRef || "Не задано"}
Applied
{configurationState?.appliedConfigurationRevisionRef || "Не подтверждено"}
);
}
function isDeviceFieldEditable(
field: DeviceProfileField,
access: DeviceFieldAccess,
capabilities: { canConfigure: boolean; canManageProject: boolean } = {
canConfigure: true,
canManageProject: true,
},
) {
return access === "managed"
&& (
(["device.displayName", "device.integrationDeviceId"].includes(field.path) && capabilities.canManageProject)
|| (field.path.startsWith("reported.configuration.") && capabilities.canConfigure)
)
&& !field.sensitive;
}
function deviceIdentifierDisplayValue(device: DeviceView) {
if (!device.identifier) return null;
if (device.identifier.value) return device.identifier.value;
const reportedImei = device.reported?.identity?.imei;
if (typeof reportedImei === "string" && reportedImei.trim()) return reportedImei;
return device.identifier.masked;
}
function cloneConfiguration(configuration: Record | null | undefined) {
if (!configuration) return {};
return JSON.parse(JSON.stringify(configuration)) as Record;
}
function writePath(target: Record, path: string, value: unknown) {
const keys = path.split(".");
let cursor: Record | unknown[] = target;
keys.forEach((key, index) => {
if (index === keys.length - 1) {
if (Array.isArray(cursor)) cursor[Number(key)] = value;
else cursor[key] = value;
return;
}
const nextKey = keys[index + 1];
const nextValue = Array.isArray(cursor) ? cursor[Number(key)] : cursor[key];
if (!nextValue || typeof nextValue !== "object") {
const created: Record | unknown[] = /^\d+$/.test(nextKey) ? [] : {};
if (Array.isArray(cursor)) cursor[Number(key)] = created;
else cursor[key] = created;
cursor = created;
} else {
cursor = nextValue as Record | unknown[];
}
});
}
function normalizeDraftValue(value: string | boolean, field: DeviceProfileField) {
if (field.valueKind === "number") return value === "" ? null : Number(value);
return value;
}
function AccessBadge({ access, compact = false }: { access: DeviceFieldAccess; compact?: boolean }) {
const tone = access === "managed" ? "accent" : access === "protected" ? "warning" : "neutral";
return {accessLabel(access)};
}
function AccessNotice({
access,
commandTransport,
}: {
access: DeviceFieldAccess;
commandTransport: ProjectWorkspace["policies"]["commandTransport"];
}) {
if (access === "read-only") {
return Этот блок отражает фактическое состояние устройства и не редактируется.;
}
if (access === "protected") {
return Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.;
}
return {commandTransport === "typed-service-ping-v1" ? "Изменение создаёт новую desired-ревизию. Статус Applied появится только после подтверждения устройством." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."};
}
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((value, key) => {
if (!value || typeof value !== "object") return undefined;
return (value as Record)[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;