1488 lines
60 KiB
TypeScript
1488 lines
60 KiB
TypeScript
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;
|
||
}
|