fix(device-manager): align shell with NODE.DC UX canon

This commit is contained in:
Codex
2026-08-13 11:51:08 +03:00
parent 2fa1951f51
commit 8f38c76f79
4 changed files with 108 additions and 145 deletions
@@ -0,0 +1 @@
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55"><defs><style>.cls-1{fill:#e2e1e1;}.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}</style></defs><path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"/><polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/><path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"/><path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"/><path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"/><path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"/><path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"/><path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg id="nodedc-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 93.22 54.55"><path fill="#e2e1e1" d="M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z"/><polygon fill="#e2e1e1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"/></svg>

After

Width:  |  Height:  |  Size: 315 B

+76 -96
View File
@@ -6,10 +6,10 @@ import {
ApplicationShell,
Button,
GlassSurface,
HeaderNavigation,
HeaderProfile,
HeaderWorkspace,
Icon,
IconButton,
Select,
SettingsCard,
StatusBadge,
@@ -129,10 +129,9 @@ export function DeviceManagerApp() {
[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 = Boolean(
session?.actor.ownerScopes.some((scope) => scope.ownerRef === activeOwnerRef),
);
const canCreateProject = creatableOwnerScopes.length > 0;
const canManageCollections = capabilities.has("collection.manage");
const canEnroll = capabilities.has("device.enroll");
const canClaim = capabilities.has("device.claim");
@@ -182,26 +181,18 @@ export function DeviceManagerApp() {
<ApplicationShell
header={
<AppHeader
brand={<span className="device-manager-brand">NODE.DC</span>}
brand={<img className="device-manager-brand" src="/nodedc-logo.svg" alt="NODE.DC" />}
brandHref="/"
left={
center={
<button
className="device-manager-workspace-trigger"
type="button"
aria-label="Открыть Device Core"
aria-label="Открыть навигацию Device Core"
onClick={shell.toggleNavigation}
>
<HeaderWorkspace label="Device Core" />
<HeaderWorkspace kind="mark" label="Device Core" imageUrl="/nodedc-mark.svg" />
</button>
}
center={
<HeaderNavigation
label="Device Core"
value="devices"
items={[{ value: "devices", label: "Device Core" }]}
onChange={() => shell.openNavigation()}
/>
}
right={
<HeaderProfile>
<UserProfileMenu
@@ -219,12 +210,7 @@ export function DeviceManagerApp() {
}
stage={
<DeviceStage
ownerScopes={ownerScopes}
activeOwnerRef={activeOwnerRef}
onOwnerChange={setActiveOwnerRef}
projects={visibleProjects}
canCreateProject={canCreateProject}
onCreateProject={() => setProjectDialogOpen(true)}
onOpenProject={openProject}
error={error}
onDismissError={() => setError(null)}
@@ -234,27 +220,44 @@ export function DeviceManagerApp() {
navigation={
<AdminNavigationPanel
eyebrow="DEVICE CORE"
title="Устройства"
contexts={activeProject ? [{
id: activeProject.projectRef,
label: activeProject.name,
description: activeProject.ownerScope.displayName,
active: true,
icon: <Icon name="apps" />,
}] : []}
title="Проекты"
headerActions={
<IconButton
label="Новый проект"
disabled={!canCreateProject}
onClick={() => setProjectDialogOpen(true)}
>
<Icon name="plus" size={16} />
</IconButton>
}
contextSlot={
ownerScopes.length ? (
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={setActiveOwnerRef}
searchable={ownerScopes.length > 6}
/>
<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={setActiveOwnerRef}
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={(projectRef) => openProject(projectRef)}
searchable={visibleProjects.length > 6}
/>
) : null}
</div>
) : null
}
items={activeProject ? visibleNavigationItems.map((item) => ({
@@ -262,7 +265,7 @@ export function DeviceManagerApp() {
label: item.label,
icon: <Icon name={item.icon} />,
})) : []}
activeId={shell.activeView ?? undefined}
activeId={activeProject ? activeView : undefined}
footer={<span>{session.actor.hubRole} · {projects.length} проектов</span>}
onClose={shell.closeNavigation}
onItemChange={(id) => shell.openView(id as ViewId)}
@@ -306,7 +309,8 @@ export function DeviceManagerApp() {
<ProjectDialog
open={projectDialogOpen}
ownerScope={ownerScopes.find((scope) => scope.ownerRef === activeOwnerRef) ?? null}
ownerScopes={creatableOwnerScopes}
initialOwnerRef={activeOwnerRef}
onClose={() => setProjectDialogOpen(false)}
onCreated={async () => {
setProjectDialogOpen(false);
@@ -349,61 +353,18 @@ export function DeviceManagerApp() {
}
function DeviceStage({
ownerScopes,
activeOwnerRef,
onOwnerChange,
projects,
canCreateProject,
onCreateProject,
onOpenProject,
error,
onDismissError,
}: {
ownerScopes: OwnerScopeClaim[];
activeOwnerRef: string;
onOwnerChange: (value: string) => void;
projects: ProjectSummary[];
canCreateProject: boolean;
onCreateProject: () => void;
onOpenProject: (projectRef: string, view?: ViewId) => void;
error: string | null;
onDismissError: () => void;
}) {
const totals = projects.reduce(
(sum, project) => ({
devices: sum.devices + project.counts.devices,
collections: sum.collections + project.counts.collections,
discoveries: sum.discoveries + project.counts.discoveries,
}),
{ devices: 0, collections: 0, discoveries: 0 },
);
return (
<div className="device-manager-stage">
<section className="device-manager-hero">
<div>
<span>UNIVERSAL DEVICE PLANE</span>
<h1>Все устройства в одном управляемом контуре.</h1>
<p>Проекты, безопасное подключение, инвентарь и коллекции без привязки интерфейса к конкретному производителю.</p>
</div>
<div className="device-manager-hero__actions">
{ownerScopes.length ? (
<Select
value={activeOwnerRef}
label="Контур владельца"
options={ownerScopes.map((scope) => ({
value: scope.ownerRef,
label: scope.displayName,
description: scope.scopeKind === "company" ? "Компания" : "Личный контур",
}))}
onChange={onOwnerChange}
/>
) : null}
<Button variant="primary" icon={<Icon name="plus" />} disabled={!canCreateProject} onClick={onCreateProject}>
Новый проект
</Button>
</div>
</section>
{error ? (
<div className="device-manager-alert" role="alert">
<Icon name="alert" />
@@ -412,14 +373,13 @@ function DeviceStage({
</div>
) : null}
<section className="device-manager-metrics" aria-label="Состояние контура">
<Metric label="Проекты" value={projects.length} detail="доступно пользователю" />
<Metric label="Устройства" value={totals.devices} detail="в выбранном контуре" />
<Metric label="Подключение" value={totals.discoveries} detail="ожидают решения" tone={totals.discoveries ? "warning" : "success"} />
<Metric label="Коллекции" value={totals.collections} detail="логических групп" />
</section>
<section className="device-manager-projects">
<GlassSurface className="device-manager-home" padding="lg" tone="soft">
<section className="device-manager-hero">
<span>NODEDC / DEVICE CORE</span>
<h1>Device Core</h1>
<p>Единый контур подключения, учёта и управления устройствами.</p>
</section>
<section className="device-manager-projects">
<div className="device-manager-section-title">
<div><span>DEVICE PROJECTS</span><h2>Рабочие проекты</h2></div>
<small>Доступ определяется Hub и project grants</small>
@@ -451,7 +411,8 @@ function DeviceStage({
<p>Создание доступно только в owner scope, подтверждённом Hub.</p>
</GlassSurface>
)}
</section>
</section>
</GlassSurface>
</div>
);
}
@@ -573,9 +534,10 @@ function EntityList({ items, empty }: { items: Array<{ id: string; title: string
))}</div>;
}
function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
function ProjectDialog({ open, ownerScopes, initialOwnerRef, onClose, onCreated, onError }: {
open: boolean;
ownerScope: OwnerScopeClaim | null;
ownerScopes: OwnerScopeClaim[];
initialOwnerRef: string;
onClose: () => void;
onCreated: () => Promise<void>;
onError: (reason: unknown) => void;
@@ -583,7 +545,15 @@ function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
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;
@@ -605,6 +575,16 @@ function ProjectDialog({ open, ownerScope, onClose, onCreated, onError }: {
<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} />
+30 -49
View File
@@ -205,10 +205,9 @@ textarea {
}
.device-manager-brand {
color: var(--nodedc-text-primary);
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0.15em;
display: block;
width: 124px;
height: auto;
}
.device-manager-workspace-trigger {
@@ -222,24 +221,26 @@ textarea {
.device-manager-stage {
height: 100%;
overflow: auto;
padding: clamp(2.25rem, 5vw, 5.5rem);
padding: clamp(1rem, 2.5vw, 2.5rem);
scrollbar-width: thin;
}
.device-manager-hero {
.device-manager-home {
display: flex;
min-height: calc(100% - 1px);
max-width: 1420px;
align-items: end;
justify-content: space-between;
gap: 2rem;
margin: 0 auto 2.5rem;
flex-direction: column;
justify-content: center;
gap: clamp(3rem, 7vh, 6rem);
margin: 0 auto;
padding: clamp(2.5rem, 6vw, 6rem) !important;
}
.device-manager-hero > div:first-child {
max-width: 780px;
.device-manager-hero {
max-width: 900px;
}
.device-manager-hero span,
.device-manager-hero > span,
.device-manager-section-title span {
color: rgb(var(--nodedc-accent-rgb));
font-size: 0.68rem;
@@ -248,13 +249,12 @@ textarea {
}
.device-manager-hero h1 {
max-width: 760px;
margin: 0.7rem 0 0.9rem;
color: var(--nodedc-text-primary);
font-size: clamp(2.25rem, 5vw, 5.4rem);
font-size: clamp(4rem, 8vw, 8.5rem);
font-weight: 520;
letter-spacing: -0.065em;
line-height: 0.98;
line-height: 0.9;
}
.device-manager-hero p {
@@ -265,18 +265,6 @@ textarea {
line-height: 1.55;
}
.device-manager-hero__actions {
display: flex;
min-width: min(100%, 390px);
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
}
.device-manager-hero__actions .nodedc-select-anchor {
min-width: 210px;
}
.device-manager-alert {
display: flex;
max-width: 1420px;
@@ -325,8 +313,12 @@ textarea {
}
.device-manager-projects {
max-width: 1420px;
margin: 0 auto;
width: 100%;
}
.device-manager-navigation-context {
display: grid;
gap: 0.65rem;
}
.device-manager-section-title {
@@ -355,7 +347,7 @@ textarea {
.device-project-card {
display: grid;
min-height: 210px;
min-height: 160px;
grid-template-columns: auto 1fr auto;
grid-template-rows: 1fr auto;
gap: 1rem;
@@ -890,16 +882,6 @@ textarea {
}
@media (max-width: 1100px) {
.device-manager-hero {
align-items: start;
flex-direction: column;
}
.device-manager-hero__actions {
width: 100%;
justify-content: flex-start;
}
.device-manager-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -909,25 +891,24 @@ textarea {
body { overflow: auto; }
.device-manager-stage {
padding: 2rem 1rem 4rem;
padding: 1rem;
}
.device-manager-home {
min-height: auto;
padding: 2.5rem 1.25rem !important;
}
.device-manager-hero h1 {
font-size: clamp(2.25rem, 14vw, 3.5rem);
font-size: clamp(3.4rem, 17vw, 6rem);
}
.device-manager-hero__actions,
.device-manager-panel-toolbar,
.device-manager-section-title {
align-items: stretch;
flex-direction: column;
}
.device-manager-hero__actions > *,
.device-manager-hero__actions .nodedc-select-anchor {
width: 100%;
}
.device-manager-metrics,
.device-manager-overview-grid {
grid-template-columns: 1fr;