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 = { 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 = { 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({ navigationOpen: false }); const [activeSection, setActiveSection] = useState("overview"); const [session, setSession] = useState(null); const [projects, setProjects] = useState([]); const [presentation, setPresentation] = useState(defaultPresentation); const [workspace, setWorkspace] = useState(null); const [activeOwnerRef, setActiveOwnerRef] = useState(""); const [activeProjectRef, setActiveProjectRef] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [projectDialogOpen, setProjectDialogOpen] = useState(false); const [collectionDialogOpen, setCollectionDialogOpen] = useState(false); const [enrollmentDialogOpen, setEnrollmentDialogOpen] = useState(false); const [claimEnrollment, setClaimEnrollment] = useState(null); const [projectSettingsOpen, setProjectSettingsOpen] = useState(false); const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false); const [inventoryDetail, setInventoryDetail] = useState(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
Подключаем Device Core…
; } const activeView = shell.activeView; const selectedInventoryDevice = inventoryDetail ? workspace?.devices.find((device) => device.deviceRef === inventoryDetail.deviceRef) ?? null : null; return ( <> } brandHref="/" center={ <> } right={ setEnvironmentSettingsOpen(true), }] : []), { id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" }, ]} /> } /> } stage={ setError(null)} onOpenSection={openSection} /> } navigationOpen={shell.navigationOpen} navigation={ {activeSection === "devices" && activeProject ? ( route.lifecycleState === "active")} onClick={() => setEnrollmentDialogOpen(true)} > ) : activeSection === "overview" ? ( setProjectDialogOpen(true)} > ) : null} {activeProject ? ( setProjectSettingsOpen(true)} > ) : null} } contextSlot={ ownerScopes.length ? (
({ value: project.projectRef, label: project.name, description: project.access.projectRole || "read", }))} onChange={selectProject} searchable={visibleProjects.length > 6} /> ) : null}
) : null } items={activeProject ? visibleNavigationItems.map((item) => ({ id: item.id, label: item.label, icon: , })) : []} activeId={activeProject && activeView ? activeView : undefined} footer={{session.actor.hubRole} · {projects.length} проектов} onClose={shell.closeNavigation} onItemChange={(id) => openTechnicalView(id as ViewId)} /> } contentOpen={shell.contentOpen} contentExpanded={shell.contentExpanded} content={ activeProject && activeView ? ( ) : undefined} utilityActions={[{ label: "Обновить данные", icon: "refresh", onClick: () => refreshWorkspace().catch((reason) => setError(errorText(reason))), }]} > setError(errorText(reason))} onCreateCollection={() => setCollectionDialogOpen(true)} onClaim={setClaimEnrollment} onInventoryDetailChange={setInventoryDetail} /> ) : null } /> setProjectDialogOpen(false)} onCreated={async () => { setProjectDialogOpen(false); await refreshProjects(); }} onError={(reason) => setError(errorText(reason))} /> setCollectionDialogOpen(false)} onCreated={async () => { setCollectionDialogOpen(false); await refreshWorkspace(); }} onError={(reason) => setError(errorText(reason))} /> setClaimEnrollment(null)} onClaimed={async () => { setClaimEnrollment(null); await refreshWorkspace(); }} onError={(reason) => setError(errorText(reason))} /> setEnrollmentDialogOpen(false)} onCreated={async () => { setEnrollmentDialogOpen(false); await refreshWorkspace(); }} onError={(reason) => setError(errorText(reason))} /> setProjectSettingsOpen(false)} onSaved={async (nextPresentation) => { setPresentation(nextPresentation); setProjectSettingsOpen(false); await refreshWorkspace(); }} onError={(reason) => setError(errorText(reason))} /> 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 ( ( )} > {({ close }) => (
Контур и проект {activeProject?.name || activeOwner?.displayName || "Не выбрано"}
Контуры {ownerScopes.map((scope) => ( ))}
Проекты {projects.filter((project) => project.ownerScope.ownerRef === activeOwnerRef).map((project) => ( ))}
)}
); } 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 (
{error ? ( {error} ) : null}
); } 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 ; } return (