Files
NODEDC_DEVICE_CORE/apps/device-manager/src/DeviceInventoryView.tsx
T

677 lines
27 KiB
TypeScript

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 (
<div className="device-detail-header-tools">
<Select
className="device-detail-section-select"
label="Раздел устройства"
value={activeSection?.id ?? catalog.sections[0]?.id ?? "passport"}
options={catalog.sections.map((section) => ({
value: section.id,
label: section.label,
}))}
onChange={(sectionId) => onDetailChange({ ...detail, sectionId })}
placement="bottom-end"
minMenuWidth={320}
menuWidth="anchor"
variant="split"
/>
<IconButton
label={detail.editing ? "Завершить редактирование" : "Редактировать устройство"}
disabled={!canEdit}
data-active={detail.editing || undefined}
onClick={() => onDetailChange({
...detail,
editing: !detail.editing,
})}
>
<Icon name="edit" size={17} />
</IconButton>
</div>
);
}
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<void>;
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 (
<DeviceDetailView
device={selectedDevice}
detail={detail}
workspace={workspace}
canConfigure={canConfigure}
canManageProject={canManageProject}
onDetailChange={onDetailChange}
onSaved={onPoll}
onError={onError}
onBack={() => onDetailChange(null)}
/>
);
}
return (
<div className="device-inventory">
<div className="device-manager-panel-toolbar device-inventory__toolbar">
<div>
<strong>Реестр устройств</strong>
<p>{workspace.devices.length} зарегистрировано · {pendingEnrollments.length} ожидают подключения</p>
</div>
</div>
<div className="device-inventory__filters" aria-label="Фильтры устройств">
<Select
label="Состояние"
value={statusFilter}
options={[
{ value: "all", label: "Все устройства" },
{ value: "active", label: "Активные" },
{ value: "inactive", label: "Неактивные" },
{ value: "pending", label: "Ожидают подключения" },
]}
onChange={setStatusFilter}
/>
<Select
label="Сортировка"
value={sortOrder}
options={[
{ value: "activity", label: "Сначала активные" },
{ value: "last-seen", label: "По последней активности" },
{ value: "name", label: "По имени" },
]}
onChange={setSortOrder}
disabled={statusFilter === "pending"}
/>
</div>
{statusFilter !== "pending" && !deviceRows.length ? (
<GlassSurface className="device-manager-empty device-inventory__empty" padding="lg" tone="soft">
<Icon name="inbox" size={24} />
<h3>{workspace.devices.length ? "Устройств с таким состоянием нет" : "В проекте пока нет устройств"}</h3>
<p>Добавьте разрешённый трекер через «плюс». Идентификатор попадёт в Device Core по защищённому процессу подключения.</p>
</GlassSurface>
) : statusFilter !== "pending" ? (
<GlassSurface className="device-inventory-table" padding="sm" tone="soft" role="table" aria-label="Устройства проекта">
<div className="device-inventory-table__head" role="row">
<span role="columnheader">Устройство</span>
<span role="columnheader">Профиль</span>
<span role="columnheader">IMEI</span>
<span role="columnheader">ID интеграционного устройства</span>
<span role="columnheader">Канал</span>
<span role="columnheader">Последний пакет</span>
<span aria-hidden="true" />
</div>
{deviceRows.map(({ device, session, online, lastSeenAt }) => {
return (
<Button
key={device.deviceRef}
type="button"
variant="ghost"
className="device-inventory-row"
role="row"
onClick={() => onDetailChange({
deviceRef: device.deviceRef,
sectionId: getDeviceProfileCatalog(device.modelProfileRef).sections[0]?.id ?? "passport",
editing: false,
})}
>
<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">{deviceIdentifierDisplayValue(device) || "не назначен"}</span>
<span role="cell">{device.integrationDeviceId || "не назначен"}</span>
<span role="cell"><StatusBadge tone={online ? "success" : "neutral"}>{online ? "Онлайн" : session?.lifecycleState || device.lifecycleState}</StatusBadge></span>
<span role="cell">{formatDate(lastSeenAt)}</span>
<span className="device-inventory-row__open" role="cell" aria-hidden="true"><Icon name="chevron-right" size={16} /></span>
</Button>
);
})}
</GlassSurface>
) : null}
{(statusFilter === "all" || statusFilter === "pending") ? (
<section className="device-inventory__pending" aria-label="Ожидают подключения">
<div className="device-inventory__section-heading">
<div>
<strong>Ожидают подключения</strong>
<p>Разрешённые идентификаторы и обнаруженные устройства.</p>
</div>
<StatusBadge tone={pendingEnrollments.length ? "warning" : "neutral"}>{pendingEnrollments.length}</StatusBadge>
</div>
{pendingEnrollments.length ? pendingEnrollments.map((enrollment) => (
<SettingsCard
key={enrollment.enrollmentIntentRef}
eyebrow={enrollment.lifecycleState}
title={enrollment.displayName}
description={`${enrollment.modelProfileRef} · ${enrollment.expectedIdentifier.masked}`}
actions={enrollment.lifecycleState === "observed" && enrollment.observedDiscoveryRef ? (
<Button size="compact" variant="primary" disabled={!canClaim} onClick={() => onClaim(enrollment)}>
Принять устройство
</Button>
) : <StatusBadge>{enrollment.lifecycleState}</StatusBadge>}
>
<p className="device-manager-card-copy">После первого пакета устройство можно принять в реестр. Исходный идентификатор в интерфейсе не раскрывается.</p>
</SettingsCard>
)) : (
<GlassSurface className="device-manager-panel-empty device-inventory__pending-empty" padding="md" tone="soft">
Нет ожидающих подключений.
</GlassSurface>
)}
</section>
) : null}
</div>
);
}
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<void>;
onError: (reason: unknown) => void;
onBack: () => void;
}) {
const catalog = getDeviceProfileCatalog(device.modelProfileRef);
const detailRef = useRef<HTMLDivElement>(null);
const [draftValues, setDraftValues] = useState<Record<string, string | boolean>>({});
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<HTMLElement>(".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 (
<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>{identifierDisplayValue || "Идентификатор не назначен"} · {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>
<GlassSurface className="device-detail__connection" padding="md" tone="soft">
<div><span>Состояние связи</span><strong>{session?.lifecycleState || device.session?.state || "Нет сессии"}</strong></div>
<div><span>Маршрут</span><strong>{session?.routeName || "Не определён"}</strong></div>
<div><span>Протокол</span><strong>{session?.protocol || profile?.protocol || "Нет данных"}</strong></div>
<div><span>Последняя активность</span><strong>{formatDate(session?.lastSeenAt || device.session?.lastSeenAt)}</strong></div>
<div><span>Пакеты</span><strong>{session?.frameCount ?? 0}</strong></div>
<div><span>Подключено</span><strong>{formatDate(session?.connectedAt)}</strong></div>
</GlassSurface>
<div className="device-detail__layout">
<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 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 (
<GlassSurface key={item.key} className="device-detail-field" padding="sm" tone="soft" data-access={access} data-editing={editable || undefined}>
{editable && item.valueKind === "boolean" ? (
<Switch
checked={Boolean(value)}
label={item.label}
disabled={saving}
onChange={(checked) => setDraftValues((current) => ({ ...current, [item.path]: checked }))}
/>
) : editable ? (
<TextField
label={item.label}
hint={item.unit}
description={item.description}
type={item.valueKind === "number" ? "number" : "text"}
value={value === undefined || value === null ? "" : String(value)}
disabled={saving}
onChange={(event) => setDraftValues((current) => ({ ...current, [item.path]: event.target.value }))}
/>
) : (
<>
<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}
</>
)}
</GlassSurface>
);
})}
</div>
{detail.editing ? (
<div className="device-detail-edit-actions">
<Button
type="button"
variant="ghost"
disabled={saving}
onClick={() => {
setDraftValues({});
onDetailChange({ ...detail, editing: false });
}}
>
Отменить
</Button>
<Button
type="button"
variant="primary"
icon={<Icon name="save" />}
disabled={saving || !Object.keys(draftValues).length}
onClick={() => void saveDeviceChanges()}
>
{saving ? "Сохраняем…" : "Сохранить"}
</Button>
</div>
) : null}
<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 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<string, unknown> | null | undefined) {
if (!configuration) return {};
return JSON.parse(JSON.stringify(configuration)) as Record<string, unknown>;
}
function writePath(target: Record<string, unknown>, path: string, value: unknown) {
const keys = path.split(".");
let cursor: Record<string, unknown> | 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<string, unknown> | unknown[] = /^\d+$/.test(nextKey) ? [] : {};
if (Array.isArray(cursor)) cursor[Number(key)] = created;
else cursor[key] = created;
cursor = created;
} else {
cursor = nextValue as Record<string, unknown> | 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 <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 <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="lock" size={15} /><span>Этот блок отражает фактическое состояние устройства и не редактируется.</span></GlassSurface>;
}
if (access === "protected") {
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="shield" size={15} /><span>Операция требует отдельного подтверждения. Обновление прошивки пилотного B2 запрещено.</span></GlassSurface>;
}
return <GlassSurface className="device-detail-notice" padding="sm" tone="soft"><Icon name="settings" size={15} /><span>{commandTransport === "typed-service-ping-v1" ? "Изменение создаёт новую desired-ревизию. Статус Applied появится только после подтверждения устройством." : "Настройка поддерживается моделью, но запись включится только после запуска двустороннего командного канала."}</span></GlassSurface>;
}
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;