feat: establish standalone Device Core repository
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user