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

1488 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type FormEvent } from "react";
import {
AdminNavigationPanel,
AppHeader,
ApplicationPanel,
ApplicationShell,
Button,
ColorField,
Dropdown,
FeatureSettingsWindow,
GlassSurface,
HeaderNavigation,
HeaderProfile,
HeaderWorkspace,
Icon,
type IconName,
IconButton,
MediaSourceField,
SegmentedControl,
Select,
SettingsCard,
StatusBadge,
Switch,
TextAreaField,
TextField,
UserProfileMenu,
Window,
WindowFooterActions,
useApplicationWorkspace,
} from "@nodedc/ui-react";
import { applyNodedcTheme } from "@nodedc/ui-core";
import {
claimDevice,
ensureCollection,
ensureEnrollmentIntent,
ensureOwnerScope,
ensureProject,
loadPresentation,
loadProjects,
loadSession,
loadWorkspace,
saveEnvironmentPresentation,
saveProjectPresentation,
uploadPresentationMedia,
} from "./api";
import type {
DeviceManagerMediaValue,
DeviceManagerEnvironmentOverview,
DeviceManagerPresentation,
DeviceManagerProjectPresentation,
DeviceManagerSession,
DeviceManagerTheme,
EnrollmentView,
OwnerScopeClaim,
ProjectSummary,
ProjectWorkspace,
} from "./types";
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
import {
DeviceControlView,
type ControlViewId,
} from "./DeviceControlViews";
import {
DeviceDetailHeaderTools,
DeviceInventoryView,
type DeviceInventoryDetailState,
} from "./DeviceInventoryView";
type ViewId =
| "overview"
| "inventory"
| "collections"
| ControlViewId;
type PrimarySection = "overview" | "devices" | "infrastructure" | "management" | "administration";
type NavigationItem = {
id: ViewId;
label: string;
icon: IconName;
capability: string | null;
};
const primarySections: Array<{ value: PrimarySection; label: string }> = [
{ value: "overview", label: "Обзор" },
{ value: "devices", label: "Устройства" },
{ value: "infrastructure", label: "Инфраструктура" },
{ value: "management", label: "Управление" },
{ value: "administration", label: "Администрирование" },
];
const sectionNavigation: Record<PrimarySection, NavigationItem[]> = {
overview: [
{ id: "overview", label: "Обзор проекта", icon: "grid", capability: null },
],
devices: [
{ id: "inventory", label: "Устройства", icon: "apps", capability: "inventory.read" },
{ id: "collections", label: "Коллекции", icon: "folder", capability: "inventory.read" },
],
infrastructure: [
{ id: "hosts", label: "VPS и хосты", icon: "building", capability: "telemetry.observe" },
{ id: "infrastructure", label: "Edges и маршруты", icon: "globe", capability: "telemetry.observe" },
{ id: "catalog", label: "Модели и адаптеры", icon: "database", capability: "project.read" },
],
management: [
{ id: "bindings", label: "Связи с Foundry", icon: "external", capability: "binding.manage" },
{ id: "commands", label: "Команды", icon: "target", capability: "command.plan" },
{ id: "settings", label: "Конфигурации", icon: "settings", capability: "configuration.read" },
],
administration: [
{ id: "audit", label: "Аудит", icon: "clipboard", capability: "audit.read" },
{ id: "access", label: "Доступ", icon: "users", capability: "access.manage" },
],
};
const sectionLabels: Record<PrimarySection, string> = {
overview: "Обзор",
devices: "Устройства",
infrastructure: "Инфраструктура",
management: "Управление",
administration: "Администрирование",
};
const emptyMedia = (): DeviceManagerMediaValue => ({
source: "file",
url: "",
fileName: null,
fileSrc: null,
});
const defaultPresentation = (): DeviceManagerPresentation => ({
environment: {
theme: "dark",
accentHex: "#f5f5f5",
overview: {
headerLabel: "Device Core",
eyebrow: "NODEDC / DEVICE CORE",
title: "Device Core",
description: "Единый контур подключения, учёта и управления устройствами.",
primarySection: "devices",
secondarySection: null,
background: {
enabled: false,
imageDurationSeconds: 10,
items: [],
},
},
},
projects: {},
});
const emptyProjectPresentation = (): DeviceManagerProjectPresentation => ({
icon: emptyMedia(),
teaser: emptyMedia(),
});
function mediaSource(value?: DeviceManagerMediaValue | null) {
if (!value) return null;
return value.source === "url" ? value.url.trim() || null : value.fileSrc;
}
function cloneOverview(value: DeviceManagerEnvironmentOverview): DeviceManagerEnvironmentOverview {
return {
...value,
background: {
...value.background,
items: value.background.items.map((item) => ({ ...item })),
},
};
}
function hexToRgb(value: string): [number, number, number] {
const match = /^#([0-9a-f]{6})$/i.exec(value.trim());
if (!match) return [185, 255, 74];
return [
Number.parseInt(match[1].slice(0, 2), 16),
Number.parseInt(match[1].slice(2, 4), 16),
Number.parseInt(match[1].slice(4, 6), 16),
];
}
export function DeviceManagerApp() {
const shell = useApplicationWorkspace<ViewId>({ navigationOpen: false });
const [activeSection, setActiveSection] = useState<PrimarySection>("overview");
const [session, setSession] = useState<DeviceManagerSession | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [presentation, setPresentation] = useState<DeviceManagerPresentation>(defaultPresentation);
const [workspace, setWorkspace] = useState<ProjectWorkspace | null>(null);
const [activeOwnerRef, setActiveOwnerRef] = useState("");
const [activeProjectRef, setActiveProjectRef] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projectDialogOpen, setProjectDialogOpen] = useState(false);
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false);
const [claimEnrollment, setClaimEnrollment] = useState<EnrollmentView | null>(null);
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [inventoryDetail, setInventoryDetail] = useState<DeviceInventoryDetailState | null>(null);
const refreshProjects = async () => {
const next = await loadProjects();
setProjects(next);
setActiveProjectRef((current) =>
next.some((project) => project.projectRef === current)
? current
: next[0]?.projectRef ?? ""
);
return next;
};
useEffect(() => {
let active = true;
Promise.all([loadSession(), loadProjects(), loadPresentation()])
.then(([nextSession, nextProjects, nextPresentation]) => {
if (!active) return;
setSession(nextSession);
setProjects(nextProjects);
setPresentation(nextPresentation);
const ownerRef = nextProjects[0]?.ownerScope.ownerRef
|| nextSession.actor.ownerScopes[0]?.ownerRef
|| "";
setActiveOwnerRef(ownerRef);
setActiveProjectRef(nextProjects[0]?.projectRef ?? "");
})
.catch((reason) => active && setError(errorText(reason)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, []);
useEffect(() => {
applyNodedcTheme(document.documentElement, {
theme: presentation.environment.theme,
accent: hexToRgb(presentation.environment.accentHex),
});
}, [presentation.environment.accentHex, presentation.environment.theme]);
useEffect(() => {
if (!activeProjectRef) {
setWorkspace(null);
return;
}
let active = true;
loadWorkspace(activeProjectRef)
.then((next) => active && setWorkspace(next))
.catch((reason) => active && setError(errorText(reason)));
return () => { active = false; };
}, [activeProjectRef]);
const ownerScopes = useMemo(
() => mergeOwnerScopes(session?.actor.ownerScopes ?? [], projects),
[projects, session],
);
const visibleProjects = useMemo(
() => projects.filter((project) => !activeOwnerRef || project.ownerScope.ownerRef === activeOwnerRef),
[activeOwnerRef, projects],
);
const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
const creatableOwnerScopes = session?.actor.ownerScopes ?? [];
const capabilities = new Set(activeProject?.access.capabilities ?? []);
const canCreateProject = creatableOwnerScopes.length > 0;
const canManageProject = capabilities.has("project.manage");
const canManageCollections = capabilities.has("collection.manage");
const canEnroll = capabilities.has("device.enroll");
const canClaim = capabilities.has("device.claim");
const canConfigure = capabilities.has("configuration.manage");
const canManageEnvironment = session?.actor.groupRefs.some(
(groupRef) => groupRef === "nodedc:superadmin" || groupRef === "group:nodedc:superadmin",
) ?? false;
const visibleNavigationItems = sectionNavigation[activeSection].filter(
(item) => item.capability === null || capabilities.has(item.capability),
);
useEffect(() => {
if (!activeOwnerRef && ownerScopes[0]) setActiveOwnerRef(ownerScopes[0].ownerRef);
}, [activeOwnerRef, ownerScopes]);
useEffect(() => {
if (
activeProjectRef
&& !visibleProjects.some((project) => project.projectRef === activeProjectRef)
) {
setActiveProjectRef(visibleProjects[0]?.projectRef ?? "");
setWorkspace(null);
}
}, [activeOwnerRef, activeProjectRef, visibleProjects]);
const selectProject = (projectRef: string) => {
setInventoryDetail(null);
setActiveProjectRef(projectRef);
shell.closeView();
};
const openSection = (section: PrimarySection) => {
if (section !== "devices") setInventoryDetail(null);
setActiveSection(section);
shell.openNavigation();
shell.closeView();
};
const openTechnicalView = (view: ViewId) => {
if (view !== "inventory") setInventoryDetail(null);
setActiveSection(sectionForView(view));
shell.openView(view);
};
const selectOwner = (ownerRef: string) => {
setActiveOwnerRef(ownerRef);
setInventoryDetail(null);
const nextProject = projects.find((project) => project.ownerScope.ownerRef === ownerRef);
if (nextProject) {
setActiveProjectRef(nextProject.projectRef);
shell.closeView();
}
else {
setActiveProjectRef("");
setWorkspace(null);
shell.closeView();
}
};
const refreshWorkspace = async () => {
if (!activeProjectRef) return;
const next = await loadWorkspace(activeProjectRef);
setWorkspace(next);
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>;
}
const activeView = shell.activeView;
const selectedInventoryDevice = inventoryDetail
? workspace?.devices.find((device) => device.deviceRef === inventoryDetail.deviceRef) ?? null
: null;
return (
<>
<ApplicationShell
header={
<AppHeader
brand={<img className="device-manager-brand" src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
center={
<>
<DeviceContextSwitcher
ownerScopes={ownerScopes}
projects={projects}
presentation={presentation}
activeOwnerRef={activeOwnerRef}
activeProjectRef={activeProjectRef}
onSelectOwner={selectOwner}
onSelectProject={selectProject}
/>
<HeaderNavigation
label="Разделы Device Core"
value={activeSection}
items={primarySections}
onChange={openSection}
/>
</>
}
right={
<HeaderProfile>
<UserProfileMenu
displayName={session.user.displayName}
subtitle={session.user.email}
avatarUrl={session.user.avatarUrl ?? undefined}
actions={[
{ id: "profile", label: "Профиль", icon: "profile", href: session.profileUrl },
...(canManageEnvironment ? [{
id: "environment-settings",
label: "Настройки интерфейса",
icon: "settings" as IconName,
onSelect: () => setEnvironmentSettingsOpen(true),
}] : []),
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
]}
/>
</HeaderProfile>
}
/>
}
stage={
<DeviceStage
presentation={presentation}
error={error}
onDismissError={() => setError(null)}
onOpenSection={openSection}
/>
}
navigationOpen={shell.navigationOpen}
navigation={
<AdminNavigationPanel
eyebrow="DEVICE CORE"
title={sectionLabels[activeSection]}
headerActions={
<>
{activeSection === "devices" && activeProject ? (
<IconButton
label="Подключить устройство"
disabled={!canEnroll || !workspace?.routes.some((route) => route.lifecycleState === "active")}
onClick={() => setEnrollmentDialogOpen(true)}
>
<Icon name="plus" size={16} />
</IconButton>
) : activeSection === "overview" ? (
<IconButton
label="Новый проект"
disabled={!canCreateProject}
onClick={() => setProjectDialogOpen(true)}
>
<Icon name="plus" size={16} />
</IconButton>
) : null}
{activeProject ? (
<IconButton
label="Настройки проекта"
disabled={!canManageProject}
onClick={() => setProjectSettingsOpen(true)}
>
<Icon name="settings" size={16} />
</IconButton>
) : null}
</>
}
contextSlot={
ownerScopes.length ? (
<div className="device-manager-navigation-context">
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={selectOwner}
searchable={ownerScopes.length > 6}
/>
{visibleProjects.length ? (
<Select
value={activeProjectRef}
label="Проект"
options={visibleProjects.map((project) => ({
value: project.projectRef,
label: project.name,
description: project.access.projectRole || "read",
}))}
onChange={selectProject}
searchable={visibleProjects.length > 6}
/>
) : null}
</div>
) : null
}
items={activeProject ? visibleNavigationItems.map((item) => ({
id: item.id,
label: item.label,
icon: <Icon name={item.icon} />,
})) : []}
activeId={activeProject && activeView ? activeView : undefined}
footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>}
onClose={shell.closeNavigation}
onItemChange={(id) => openTechnicalView(id as ViewId)}
/>
}
contentOpen={shell.contentOpen}
contentExpanded={shell.contentExpanded}
content={
activeProject && activeView ? (
<ApplicationPanel
eyebrow={activeProject.ownerScope.displayName}
title={viewTitle(activeView)}
description={activeProject.name}
expanded={shell.contentExpanded}
onExpandedChange={shell.setContentExpanded}
onClose={shell.closeView}
headerTools={activeView === "inventory" && selectedInventoryDevice && inventoryDetail ? (
<DeviceDetailHeaderTools
device={selectedInventoryDevice}
detail={inventoryDetail}
canEdit={canConfigure || canManageProject}
onDetailChange={setInventoryDetail}
/>
) : undefined}
utilityActions={[{
label: "Обновить данные",
icon: "refresh",
onClick: () => refreshWorkspace().catch((reason) => setError(errorText(reason))),
}]}
>
<ProjectView
view={activeView}
workspace={workspace}
canManageCollections={canManageCollections}
canClaim={canClaim}
canConfigure={canConfigure}
canManageProject={canManageProject}
inventoryDetail={inventoryDetail}
session={session}
onRefresh={refreshWorkspace}
onPoll={pollWorkspace}
onError={(reason) => setError(errorText(reason))}
onCreateCollection={() => setCollectionDialogOpen(true)}
onClaim={setClaimEnrollment}
onInventoryDetailChange={setInventoryDetail}
/>
</ApplicationPanel>
) : null
}
/>
<ProjectDialog
open={projectDialogOpen}
ownerScopes={creatableOwnerScopes}
initialOwnerRef={activeOwnerRef}
onClose={() => setProjectDialogOpen(false)}
onCreated={async () => {
setProjectDialogOpen(false);
await refreshProjects();
}}
onError={(reason) => setError(errorText(reason))}
/>
<CollectionDialog
open={collectionDialogOpen}
project={activeProject}
onClose={() => setCollectionDialogOpen(false)}
onCreated={async () => {
setCollectionDialogOpen(false);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
<ClaimDialog
enrollment={claimEnrollment}
project={activeProject}
onClose={() => setClaimEnrollment(null)}
onClaimed={async () => {
setClaimEnrollment(null);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
<EnrollmentDialog
open={enrollmentDialogOpen}
workspace={workspace}
onClose={() => setEnrollmentDialogOpen(false)}
onCreated={async () => {
setEnrollmentDialogOpen(false);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
<ProjectSettingsDialog
open={projectSettingsOpen}
workspace={workspace}
presentation={presentation.projects[activeProjectRef]}
onClose={() => setProjectSettingsOpen(false)}
onSaved={async (nextPresentation) => {
setPresentation(nextPresentation);
setProjectSettingsOpen(false);
await refreshWorkspace();
}}
onError={(reason) => setError(errorText(reason))}
/>
<EnvironmentSettingsDialog
open={environmentSettingsOpen}
presentation={presentation}
onClose={() => setEnvironmentSettingsOpen(false)}
onSaved={(nextPresentation) => {
setPresentation(nextPresentation);
setEnvironmentSettingsOpen(false);
}}
onError={(reason) => setError(errorText(reason))}
/>
</>
);
}
function DeviceContextSwitcher({
ownerScopes,
projects,
presentation,
activeOwnerRef,
activeProjectRef,
onSelectOwner,
onSelectProject,
}: {
ownerScopes: OwnerScopeClaim[];
projects: ProjectSummary[];
presentation: DeviceManagerPresentation;
activeOwnerRef: string;
activeProjectRef: string;
onSelectOwner: (ownerRef: string) => void;
onSelectProject: (projectRef: string) => void;
}) {
const activeOwner = ownerScopes.find((scope) => scope.ownerRef === activeOwnerRef) ?? null;
const activeProject = projects.find((project) => project.projectRef === activeProjectRef) ?? null;
const activeIcon = activeProject ? mediaSource(presentation.projects[activeProject.projectRef]?.icon) : null;
return (
<Dropdown
placement="bottom-start"
width={320}
surfaceRole="dialog"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<Button
ref={(node) => { setAnchorRef(node); setTriggerRef(node); }}
variant="ghost"
size="compact"
shape="pill"
aria-label="Выбрать контур и проект"
aria-expanded={open}
aria-controls={surfaceId}
onClick={toggle}
>
<HeaderWorkspace kind="mark" label={activeProject?.name || activeOwner?.displayName || "Device Core"} imageUrl={activeIcon || "/nodedc-mark.svg"} />
<Icon name="chevron-down" size={14} aria-hidden="true" />
</Button>
)}
>
{({ close }) => (
<div className="device-context-menu__content">
<div className="device-context-menu__heading">
<span>Контур и проект</span>
<strong>{activeProject?.name || activeOwner?.displayName || "Не выбрано"}</strong>
</div>
<div className="device-context-menu__group">
<small>Контуры</small>
{ownerScopes.map((scope) => (
<Button
key={scope.ownerRef}
variant={scope.ownerRef === activeOwnerRef ? "secondary" : "ghost"}
width="full"
className="device-context-menu__option"
onClick={() => { onSelectOwner(scope.ownerRef); close(); }}
>
<span><strong>{scope.displayName}</strong><small>{scope.scopeKind === "company" ? "Компания" : "Личный контур"}</small></span>
{scope.ownerRef === activeOwnerRef ? <Icon name="check" size={15} /> : null}
</Button>
))}
</div>
<div className="device-context-menu__group">
<small>Проекты</small>
{projects.filter((project) => project.ownerScope.ownerRef === activeOwnerRef).map((project) => (
<Button
key={project.projectRef}
variant={project.projectRef === activeProjectRef ? "secondary" : "ghost"}
width="full"
className="device-context-menu__option"
onClick={() => { onSelectProject(project.projectRef); close(); }}
>
<span><strong>{project.name}</strong><small>{project.access.projectRole || "read"}</small></span>
{project.projectRef === activeProjectRef ? <Icon name="check" size={15} /> : null}
</Button>
))}
</div>
</div>
)}
</Dropdown>
);
}
function DeviceStage({
presentation,
error,
onDismissError,
onOpenSection,
}: {
presentation: DeviceManagerPresentation;
error: string | null;
onDismissError: () => void;
onOpenSection: (section: PrimarySection) => void;
}) {
const overview = presentation.environment.overview;
const actions = [overview.primarySection, overview.secondarySection]
.filter((value, index, values): value is PrimarySection => (
Boolean(value)
&& primarySections.some((section) => section.value === value)
&& values.indexOf(value) === index
));
const hasMedia = overview.background.enabled && overview.background.items.some(
(item) => Boolean(mediaSource(item)) && Boolean(item.mediaKind),
);
return (
<div className="device-manager-stage">
{error ? (
<GlassSurface className="device-manager-alert" padding="sm" tone="soft" role="alert">
<Icon name="alert" />
<span>{error}</span>
<Button variant="ghost" size="compact" onClick={onDismissError}>Закрыть</Button>
</GlassSurface>
) : null}
<section
className="device-manager-home"
data-has-media={hasMedia ? "true" : undefined}
>
<EnvironmentBackdrop background={overview.background} />
<div className="device-manager-home__scrim" aria-hidden="true" />
<section className="device-manager-hero">
<span>{overview.eyebrow}</span>
<h1>{overview.title}</h1>
<p>{overview.description}</p>
{actions.length ? (
<div className="device-manager-hero__actions">
{actions.map((section, index) => (
<Button
key={section}
variant={index === 0 ? "primary" : "secondary"}
onClick={() => onOpenSection(section)}
>
{sectionLabels[section]}
</Button>
))}
</div>
) : null}
</section>
</section>
</div>
);
}
function EnvironmentBackdrop({
background,
}: {
background: DeviceManagerEnvironmentOverview["background"];
}) {
const playableItems = useMemo(
() => background.enabled
? background.items.filter((item) => Boolean(mediaSource(item)) && Boolean(item.mediaKind))
: [],
[background.enabled, background.items],
);
const [activeIndex, setActiveIndex] = useState(0);
const activeItem = playableItems[activeIndex] ?? null;
const source = activeItem ? mediaSource(activeItem) : null;
const playbackKey = playableItems
.map((item) => `${item.id}:${mediaSource(item) || ""}:${item.mediaKind}`)
.join("|");
useEffect(() => setActiveIndex(0), [playbackKey]);
useEffect(() => {
if (!activeItem || activeItem.mediaKind !== "image" || playableItems.length < 2) return undefined;
const timer = window.setTimeout(
() => setActiveIndex((current) => (current + 1) % playableItems.length),
background.imageDurationSeconds * 1000,
);
return () => window.clearTimeout(timer);
}, [activeItem, background.imageDurationSeconds, playableItems.length]);
if (!activeItem || !source) return null;
if (activeItem.mediaKind === "image") {
return <img key={activeItem.id} className="device-manager-home__media" src={source} alt="" />;
}
return (
<video
key={activeItem.id}
className="device-manager-home__media"
src={source}
autoPlay
loop={playableItems.length === 1}
muted
playsInline
onEnded={() => setActiveIndex((current) => (current + 1) % playableItems.length)}
/>
);
}
function Metric({ label, value, detail, tone = "neutral" }: { label: string; value: number; detail: string; tone?: "neutral" | "success" | "warning" }) {
return (
<GlassSurface className="device-manager-metric" padding="md" tone="soft">
<span>{label}</span>
<strong>{value}</strong>
<StatusBadge tone={tone}>{detail}</StatusBadge>
</GlassSurface>
);
}
function ProjectView({ view, workspace, canManageCollections, canClaim, canConfigure, canManageProject, inventoryDetail, session, onRefresh, onPoll, onError, onCreateCollection, onClaim, onInventoryDetailChange }: {
view: ViewId;
workspace: ProjectWorkspace | null;
canManageCollections: boolean;
canClaim: boolean;
canConfigure: boolean;
canManageProject: boolean;
inventoryDetail: DeviceInventoryDetailState | null;
session: DeviceManagerSession;
onRefresh: () => Promise<void>;
onPoll: () => Promise<void>;
onError: (reason: unknown) => void;
onCreateCollection: () => void;
onClaim: (enrollment: EnrollmentView) => void;
onInventoryDetailChange: (detail: DeviceInventoryDetailState | null) => void;
}) {
if (!workspace) return <div className="device-manager-panel-empty">Загружаем проект…</div>;
if (["catalog", "hosts", "infrastructure", "sessions", "bindings", "commands", "audit", "access", "settings"].includes(view)) {
return <DeviceControlView
view={view as ControlViewId}
workspace={workspace}
session={session}
onRefresh={onRefresh}
onPoll={onPoll}
onError={onError}
/>;
}
if (view === "inventory") return (
<DeviceInventoryView
workspace={workspace}
canClaim={canClaim}
canConfigure={canConfigure}
canManageProject={canManageProject}
detail={inventoryDetail}
onClaim={onClaim}
onPoll={onPoll}
onError={onError}
onDetailChange={onInventoryDetailChange}
/>
);
if (view === "collections") return (
<div className="device-manager-stack">
<div className="device-manager-panel-toolbar">
<p>Коллекции группируют устройства для последующих bindings и задач.</p>
<Button icon={<Icon name="plus" />} disabled={!canManageCollections} onClick={onCreateCollection}>Новая коллекция</Button>
</div>
<EntityList
empty="Коллекций пока нет."
items={workspace.collections.map((collection) => ({
id: collection.collectionRef,
title: collection.name,
subtitle: collection.description || collection.collectionKey,
status: `${collection.memberCount} устройств`,
tone: "neutral",
}))}
/>
</div>
);
return (
<div className="device-manager-overview-grid">
<Metric label="Устройства" value={workspace.devices.length} detail="inventory" />
<Metric label="Quarantine" value={workspace.discoveries.filter((item) => item.lifecycleState === "quarantine").length} detail="safe projection" />
<Metric label="Коллекции" value={workspace.collections.length} detail="bindings ready" />
<SettingsCard title="Права проекта" description={workspace.project.access.projectRole || "read"}>
<div className="device-manager-capabilities">
{workspace.project.access.capabilities.map((capability) => <StatusBadge key={capability}>{capability}</StatusBadge>)}
</div>
</SettingsCard>
</div>
);
}
function ProjectSettingsDialog({ open, workspace, presentation, onClose, onSaved, onError }: {
open: boolean;
workspace: ProjectWorkspace | null;
presentation?: DeviceManagerProjectPresentation;
onClose: () => void;
onSaved: (presentation: DeviceManagerPresentation) => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [section, setSection] = useState<"identity" | "media">("identity");
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [draft, setDraft] = useState<DeviceManagerProjectPresentation>(emptyProjectPresentation);
const [pending, setPending] = useState(false);
const [uploading, setUploading] = useState<"icon" | "teaser" | null>(null);
useEffect(() => {
if (!open || !workspace) return;
setSection("identity");
setName(workspace.project.name);
setDescription(workspace.project.description ?? "");
const current = presentation ?? emptyProjectPresentation();
setDraft({ icon: { ...current.icon }, teaser: { ...current.teaser } });
}, [open, presentation, workspace]);
const upload = async (kind: "icon" | "teaser", file?: File) => {
if (!file || !workspace) return;
setUploading(kind);
try {
const stored = await uploadPresentationMedia({
file,
scope: "project",
projectRef: workspace.project.projectRef,
kind,
});
setDraft((current) => ({
...current,
[kind]: { source: "file", url: "", ...stored },
}));
} catch (reason) {
onError(reason);
} finally {
setUploading(null);
}
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!workspace) return;
setPending(true);
try {
await ensureProject({
scopeKind: workspace.project.ownerScope.scopeKind,
ownerRef: workspace.project.ownerScope.ownerRef,
projectKey: workspace.project.projectKey,
name: name.trim(),
description: description.trim() || null,
});
const saved = await saveProjectPresentation(workspace.project.projectRef, draft);
await onSaved(saved.presentation);
} catch (reason) {
onError(reason);
} finally {
setPending(false);
}
};
const iconPreview = mediaSource(draft.icon);
return (
<FeatureSettingsWindow
open={open && Boolean(workspace)}
title="Настройки проекта"
subtitle={workspace?.project.ownerScope.displayName}
identity={{
title: workspace?.project.name || "Device Project",
subtitle: workspace?.project.projectKey,
avatarLabel: workspace?.project.name || "DP",
avatarUrl: iconPreview ?? undefined,
}}
sections={[
{ id: "identity", label: "Идентичность", icon: "profile", group: "Проект" },
{ id: "media", label: "Медиа", icon: "image", group: "Оформление" },
]}
activeSection={section}
onSectionChange={setSection}
onClose={onClose}
footer={
<WindowFooterActions>
<Button variant="ghost" onClick={onClose}>Отмена</Button>
<Button type="submit" form="device-project-settings-form" variant="primary" disabled={pending || Boolean(uploading) || !name.trim()}>
{pending ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
}
>
<form id="device-project-settings-form" className="device-manager-form" onSubmit={submit}>
{section === "identity" ? (
<SettingsCard title="Идентичность проекта" description="Название и описание доступны участникам во всех разделах Device Core.">
<div className="device-manager-form">
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
<TextField label="Стабильный ключ" value={workspace?.project.projectKey ?? ""} readOnly description="Используется интеграциями и после создания не изменяется." />
</div>
</SettingsCard>
) : (
<SettingsCard title="Медиа проекта" description="Иконка используется в переключателях, тизер — фоном проектного пространства.">
<div className="device-manager-presentation-form">
<MediaSourceField
label="Иконка проекта"
kindLabel="PNG · JPEG · WEBP"
source={draft.icon.source}
url={draft.icon.url}
fileName={draft.icon.fileName}
previewSrc={iconPreview}
previewKind="image"
uploading={uploading === "icon"}
accept="image/png,image/jpeg,image/webp,image/gif"
onSourceChange={(source) => setDraft((current) => ({ ...current, icon: { ...current.icon, source } }))}
onUrlChange={(url) => setDraft((current) => ({ ...current, icon: { ...current.icon, source: "url", url } }))}
onFileChange={(file) => upload("icon", file)}
/>
<MediaSourceField
label="Заставка проекта"
kindLabel="MP4 · WEBM · MOV"
source={draft.teaser.source}
url={draft.teaser.url}
fileName={draft.teaser.fileName}
previewSrc={mediaSource(draft.teaser)}
previewKind="video"
uploading={uploading === "teaser"}
accept="video/mp4,video/webm,video/quicktime,.mp4,.webm,.mov"
hint="Показывается на фоне, когда рабочая панель проекта закрыта."
onSourceChange={(source) => setDraft((current) => ({ ...current, teaser: { ...current.teaser, source } }))}
onUrlChange={(url) => setDraft((current) => ({ ...current, teaser: { ...current.teaser, source: "url", url } }))}
onFileChange={(file) => upload("teaser", file)}
/>
</div>
</SettingsCard>
)}
</form>
</FeatureSettingsWindow>
);
}
function EnvironmentSettingsDialog({ open, presentation, onClose, onSaved, onError }: {
open: boolean;
presentation: DeviceManagerPresentation;
onClose: () => void;
onSaved: (presentation: DeviceManagerPresentation) => void;
onError: (reason: unknown) => void;
}) {
const [section, setSection] = useState<"environment" | "appearance">("environment");
const [theme, setTheme] = useState<DeviceManagerTheme>(presentation.environment.theme);
const [accentHex, setAccentHex] = useState(presentation.environment.accentHex);
const [overview, setOverview] = useState<DeviceManagerEnvironmentOverview>(() => cloneOverview(presentation.environment.overview));
const [pending, setPending] = useState(false);
const [uploading, setUploading] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setSection("environment");
setTheme(presentation.environment.theme);
setAccentHex(presentation.environment.accentHex);
setOverview(cloneOverview(presentation.environment.overview));
setLocalError(null);
}, [open, presentation]);
const dirty = useMemo(() => JSON.stringify({ theme, accentHex, overview }) !== JSON.stringify(presentation.environment), [accentHex, overview, presentation.environment, theme]);
const actionOptions = useMemo(() => [
{ value: "none", label: "Не показывать", description: "Кнопка скрыта на стартовом экране" },
...primarySections.map((item) => ({
value: item.value,
label: item.label,
description: `Открыть раздел «${item.label}»`,
})),
], []);
const patchOverview = (patch: Partial<DeviceManagerEnvironmentOverview>) => {
setOverview((current) => ({ ...current, ...patch }));
};
const upload = async (_itemId: string, file: File) => {
return uploadPresentationMedia({ file, scope: "environment", kind: "background" });
};
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!overview.headerLabel.trim() || !overview.eyebrow.trim() || !overview.title.trim() || !overview.description.trim()) {
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
return;
}
if (overview.primarySection && overview.primarySection === overview.secondarySection) {
setLocalError("Быстрые кнопки должны вести в разные разделы.");
return;
}
const invalidMedia = (overview.background.enabled && !overview.background.items.length)
|| overview.background.items.some((item) => {
const source = mediaSource(item);
if (!source || !item.mediaKind) return true;
if (item.source !== "url") return false;
try {
return !["http:", "https:"].includes(new URL(source).protocol);
} catch {
return true;
}
});
if (invalidMedia) {
setLocalError("Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.");
return;
}
setLocalError(null);
setPending(true);
try {
const saved = await saveEnvironmentPresentation({
theme,
accentHex,
overview: {
...overview,
headerLabel: overview.headerLabel.trim(),
eyebrow: overview.eyebrow.trim(),
title: overview.title.trim(),
description: overview.description.trim(),
},
});
onSaved(saved.presentation);
} catch (reason) {
onError(reason);
} finally {
setPending(false);
}
};
return (
<FeatureSettingsWindow
open={open}
title="Настройки Device Core"
subtitle="Глобальное окружение"
identity={{ title: "DC", subtitle: "Device Core", avatarLabel: "DC" }}
sections={[
{ id: "environment", label: "Окружение", icon: "settings", group: "DEVICE CORE" },
{ id: "appearance", label: "Тема и акцент", icon: "image", group: "Оформление" },
]}
activeSection={section}
onSectionChange={setSection}
onClose={onClose}
footer={
<WindowFooterActions>
<Button
variant="secondary"
disabled={!dirty || pending || uploading}
onClick={() => {
setTheme(presentation.environment.theme);
setAccentHex(presentation.environment.accentHex);
setOverview(cloneOverview(presentation.environment.overview));
setLocalError(null);
}}
>
Сбросить изменения
</Button>
<Button type="submit" form="device-environment-settings-form" variant="primary" disabled={!dirty || pending || uploading}>
{pending ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
}
>
<form id="device-environment-settings-form" className="device-manager-form" onSubmit={submit}>
{section === "environment" ? (
<div className="environment-settings">
<SettingsCard
eyebrow="ОКРУЖЕНИЕ"
title="Основные элементы управления"
description="Настройте название продукта, содержание стартового экрана, подложку и быстрые переходы."
actions={(
<Switch
checked={overview.background.enabled}
label="Показывать фон"
onChange={(enabled) => {
if (enabled && !overview.background.items.length) {
setLocalError("Сначала добавьте медиаконтент.");
return;
}
setLocalError(null);
setOverview((current) => ({
...current,
background: { ...current.background, enabled },
}));
}}
/>
)}
>
<div className="environment-settings__editor">
<div className="environment-settings__copy">
<TextField
label="Название продукта"
value={overview.headerLabel}
maxLength={40}
onChange={(event) => patchOverview({ headerLabel: event.currentTarget.value })}
/>
<TextField
label="Надзаголовок"
value={overview.eyebrow}
maxLength={80}
onChange={(event) => patchOverview({ eyebrow: event.currentTarget.value })}
/>
<TextField
label="Основной заголовок"
value={overview.title}
maxLength={120}
onChange={(event) => patchOverview({ title: event.currentTarget.value })}
/>
<TextAreaField
label="Описание"
value={overview.description}
maxLength={500}
rows={3}
onChange={(event) => patchOverview({ description: event.currentTarget.value })}
/>
</div>
<div className="environment-settings__quick-actions">
<div>
<span>Кнопка 1</span>
<Select
label="Выбрать первую быструю кнопку"
value={(overview.primarySection ?? "none") as PrimarySection | "none"}
options={actionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => patchOverview({ primarySection: value === "none" ? null : value })}
/>
</div>
<div>
<span>Кнопка 2</span>
<Select
label="Выбрать вторую быструю кнопку"
value={(overview.secondarySection ?? "none") as PrimarySection | "none"}
options={actionOptions}
variant="split"
menuWidth="anchor"
onChange={(value) => patchOverview({ secondarySection: value === "none" ? null : value })}
/>
</div>
</div>
<EnvironmentMediaPlaylistEditor
background={overview.background}
disabled={pending}
error={localError}
onBusyChange={setUploading}
onChange={(background) => {
setLocalError(null);
setOverview((current) => ({ ...current, background }));
}}
onUpload={upload}
/>
</div>
</SettingsCard>
</div>
) : (
<SettingsCard title="Внешний вид" description="Использует каноническую систему темы и accent tokens NODE.DC.">
<div className="device-manager-presentation-form">
<SegmentedControl
label="Цветовая тема"
value={theme}
items={[{ value: "dark", label: "Тёмная" }, { value: "light", label: "Светлая" }]}
onChange={setTheme}
/>
<ColorField label="Акцентный цвет" value={accentHex} onChange={setAccentHex} />
</div>
</SettingsCard>
)}
</form>
</FeatureSettingsWindow>
);
}
function EntityList({ items, empty }: { items: Array<{ id: string; title: string; subtitle: string; status: string; tone: "neutral" | "success" | "warning" }>; empty: string }) {
if (!items.length) return <div className="device-manager-panel-empty">{empty}</div>;
return <div className="device-manager-entity-list">{items.map((item) => (
<GlassSurface key={item.id} className="device-manager-entity" padding="md" tone="soft">
<span className="device-manager-entity__icon"><Icon name="circle" /></span>
<span className="device-manager-entity__body"><strong>{item.title}</strong><small>{item.subtitle}</small></span>
<StatusBadge tone={item.tone}>{item.status}</StatusBadge>
</GlassSurface>
))}</div>;
}
function ProjectDialog({ open, ownerScopes, initialOwnerRef, onClose, onCreated, onError }: {
open: boolean;
ownerScopes: OwnerScopeClaim[];
initialOwnerRef: string;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [description, setDescription] = useState("");
const [ownerRef, setOwnerRef] = useState(initialOwnerRef);
const [pending, setPending] = useState(false);
useEffect(() => {
if (!open) return;
setOwnerRef(ownerScopes.some((scope) => scope.ownerRef === initialOwnerRef)
? initialOwnerRef
: ownerScopes[0]?.ownerRef ?? "");
}, [initialOwnerRef, open, ownerScopes]);
const ownerScope = ownerScopes.find((scope) => scope.ownerRef === ownerRef) ?? null;
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!ownerScope) return;
setPending(true);
try {
await ensureOwnerScope(ownerScope);
await ensureProject({
scopeKind: ownerScope.scopeKind,
ownerRef: ownerScope.ownerRef,
projectKey: key,
name,
description: description.trim() || null,
});
setName(""); setKey(""); setDescription("");
await onCreated();
} catch (reason) { onError(reason); } finally { setPending(false); }
};
return <Window open={open} title="Новый Device Project" subtitle={ownerScope?.displayName || "Owner scope недоступен"} onClose={onClose} footer={
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-project-form" variant="primary" disabled={pending || !ownerScope}>{pending ? "Создаём…" : "Создать"}</Button></WindowFooterActions>
}>
<form id="device-project-form" className="device-manager-form" onSubmit={submit}>
<Select
label="Контур проекта"
value={ownerRef}
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Коммерческий контур" : "Личный контур",
}))}
onChange={setOwnerRef}
/>
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ проекта" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" description="Стабильный ключ: латиница, цифры и дефис." />
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
</form>
</Window>;
}
function CollectionDialog({ open, project, onClose, onCreated, onError }: {
open: boolean; project: ProjectSummary | null; onClose: () => void; onCreated: () => Promise<void>; onError: (reason: unknown) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [description, setDescription] = useState("");
const [pending, setPending] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault(); if (!project) return; setPending(true);
try {
await ensureCollection({ projectRef: project.projectRef, collectionKey: key, name, description: description.trim() || null });
setName(""); setKey(""); setDescription(""); await onCreated();
} catch (reason) { onError(reason); } finally { setPending(false); }
};
return <Window open={open} title="Новая коллекция" subtitle={project?.name} onClose={onClose} footer={
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-collection-form" variant="primary" disabled={pending || !project}>{pending ? "Сохраняем…" : "Создать"}</Button></WindowFooterActions>
}>
<form id="device-collection-form" className="device-manager-form" onSubmit={submit}>
<TextField label="Название" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ коллекции" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<TextAreaField label="Описание" value={description} onChange={(event) => setDescription(event.target.value)} maxLength={2000} />
</form>
</Window>;
}
function ClaimDialog({ enrollment, project, onClose, onClaimed, onError }: {
enrollment: EnrollmentView | null; project: ProjectSummary | null; onClose: () => void; onClaimed: () => Promise<void>; onError: (reason: unknown) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => { if (enrollment) setName(enrollment.displayName); }, [enrollment]);
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!project || !enrollment?.observedDiscoveryRef) return;
setPending(true);
try {
await claimDevice({
projectRef: project.projectRef,
enrollmentIntentRef: enrollment.enrollmentIntentRef,
discoveryRef: enrollment.observedDiscoveryRef,
deviceKey: key,
displayName: name,
});
setName(""); setKey(""); await onClaimed();
} catch (reason) { onError(reason); } finally { setPending(false); }
};
return <Window open={Boolean(enrollment)} title="Принять устройство" subtitle={enrollment?.expectedIdentifier.masked} onClose={onClose} footer={
<WindowFooterActions><Button variant="ghost" onClick={onClose}>Отмена</Button><Button type="submit" form="device-claim-form" variant="primary" disabled={pending || !enrollment}>{pending ? "Проверяем…" : "Принять"}</Button></WindowFooterActions>
}>
<form id="device-claim-form" className="device-manager-form" onSubmit={submit}>
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ устройства" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<p className="device-manager-card-copy">Claim использует только ссылки на enrollment и discovery. Исходный идентификатор не запрашивается повторно.</p>
</form>
</Window>;
}
function EnrollmentDialog({ open, workspace, onClose, onCreated, onError }: {
open: boolean;
workspace: ProjectWorkspace | null;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
}) {
const activeRoutes = useMemo(
() => workspace?.routes.filter((route) => route.lifecycleState === "active") ?? [],
[workspace],
);
const [routeRef, setRouteRef] = useState("");
const [name, setName] = useState("");
const [key, setKey] = useState("");
const [imei, setImei] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [pending, setPending] = useState(false);
useEffect(() => {
if (!activeRoutes.some((route) => route.routeRef === routeRef)) {
setRouteRef(activeRoutes[0]?.routeRef ?? "");
}
}, [activeRoutes, routeRef]);
useEffect(() => {
if (!open) setImei("");
}, [open]);
const submit = async (event: FormEvent) => {
event.preventDefault();
const route = activeRoutes.find((item) => item.routeRef === routeRef);
if (!workspace || !route) return;
setPending(true);
try {
await ensureEnrollmentIntent({
projectRef: workspace.project.projectRef,
enrollmentKey: key,
routeRef: route.routeRef,
modelProfileRef: route.modelProfileRef,
displayName: name,
identifier: { kind: "imei", value: imei },
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
});
setImei("");
setName("");
setKey("");
setExpiresAt("");
await onCreated();
} catch (reason) {
onError(reason);
} finally {
setPending(false);
}
};
return <Window open={open} title="Подключить устройство" subtitle={workspace?.project.name} onClose={onClose} footer={
<WindowFooterActions>
<Button variant="ghost" onClick={onClose}>Отмена</Button>
<Button type="submit" form="device-enrollment-form" variant="primary" disabled={pending || !workspace || !routeRef}>
{pending ? "Защищаем идентификатор…" : "Создать enrollment"}
</Button>
</WindowFooterActions>
}>
<form id="device-enrollment-form" className="device-manager-form" onSubmit={submit}>
<Select
label="Активный маршрут"
value={routeRef}
onChange={setRouteRef}
options={activeRoutes.map((route) => ({
value: route.routeRef,
label: route.displayName,
description: `${route.protocol} · ${route.modelProfileRef}`,
}))}
/>
<TextField label="Название устройства" value={name} onChange={(event) => setName(event.target.value)} required maxLength={160} />
<TextField label="Ключ enrollment" value={key} onChange={(event) => setKey(event.target.value.toLowerCase())} required pattern="[a-z][a-z0-9-]{1,62}" />
<TextField label="IMEI" value={imei} onChange={(event) => setImei(event.target.value.replace(/\D/g, "").slice(0, 15))} required pattern="[0-9]{15}" inputMode="numeric" autoComplete="off" />
<TextField label="Истекает" type="datetime-local" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} description="Опционально. Время будет сохранено в UTC." />
<p className="device-manager-card-copy">IMEI передаётся один раз по HTTPS в Device Control Core. В базе, audit и ответе останутся только HMAC и маска.</p>
</form>
</Window>;
}
function mergeOwnerScopes(claims: OwnerScopeClaim[], projects: ProjectSummary[]) {
const scopes = new Map(claims.map((scope) => [scope.ownerRef, scope]));
for (const project of projects) {
if (!scopes.has(project.ownerScope.ownerRef)) {
scopes.set(project.ownerScope.ownerRef, {
scopeKind: project.ownerScope.scopeKind,
ownerRef: project.ownerScope.ownerRef,
displayName: project.ownerScope.displayName,
});
}
}
return [...scopes.values()];
}
function viewTitle(view: ViewId) {
return ({
overview: "Обзор проекта",
inventory: "Устройства",
collections: "Коллекции",
catalog: "Модели и адаптеры",
hosts: "VPS и хосты",
infrastructure: "Edges и маршруты",
sessions: "Gateway sessions",
bindings: "Data bindings",
commands: "Command ledger",
audit: "Immutable audit",
access: "Доступ к проекту",
settings: "Настройки и конфигурации",
})[view];
}
function sectionForView(view: ViewId): PrimarySection {
if (view === "overview") return "overview";
if (view === "inventory" || view === "collections" || view === "sessions") return "devices";
if (view === "catalog" || view === "hosts" || view === "infrastructure") return "infrastructure";
if (view === "bindings" || view === "commands" || view === "settings") return "management";
return "administration";
}
function errorText(reason: unknown) {
const value = reason instanceof Error ? reason.message : String(reason || "device_manager_error");
const labels: Record<string, string> = {
device_project_capability_denied: "Недостаточно прав в выбранном проекте.",
device_owner_scope_access_denied: "Hub не подтвердил право управлять этим контуром.",
device_manager_auth_unavailable: "Проверка Hub-сессии временно недоступна.",
};
return labels[value] || value;
}