677 lines
27 KiB
TypeScript
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;
|