import { useEffect, useState, useSyncExternalStore } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' import { api } from '../api/client' import { session } from '../api/session' import { live } from '../api/ws' import { useAlerts } from '../hooks/useAlerts' import type { Permission } from '../types' import { LogoMark } from './Logo' import { plural, useWideScreen } from './ui' interface NavItem { to: string label: string icon: string /** * Право, без якого пункт не показується. * * Необов'язкове: є сторінки, які належать кожному, хто увійшов * (власний профіль), і вигадувати для них право означало б брехати * про модель доступу заради типу. */ perm?: Permission /** Показувати лічильник алертів на цьому пункті. */ badge?: boolean } interface NavGroup { /** * Стійкий ключ групи — під ним живе згорнутий стан у localStorage. * * Окремо від `title` навмисно: підпис міняють заради людини (і в цьому * файлі його вже міняли), а ключ не міняють ніколи. Якби ключем був * заголовок, кожне переписування слова тихо скидало б налаштування * всім, хто його вже зробив, — і виглядало б це як «меню саме * розгорнулось», без жодної підказки чому. */ id: string title: string items: NavItem[] } /** * Меню згруповане за ЖИТТЄВИМ ЦИКЛОМ даних, а не за темами. * * Порядок груп повторює шлях, яким дані проходять через систему: * * Моніторинг те, що вже зібрано й показане людині * Інвентар з чого збирають * Конфігурації окремий вид зібраного — текст конфігу * Збір даних чим і як збирають * Сповіщення що робити, коли зібране виглядає погано * Адміністрування хто це все бачить * * Це не той самий поділ, що був: «Мережа» змішувала інвентар із * конфігами, а «Налаштування збору» тримало канали сповіщень — тобто * те, що збором не є взагалі. Перевірка проста: людина, яка шукає * «куди приходить повідомлення про аварію», не має заходити в розділ * про опитування пристроїв. * * Три сторінки взагалі не мали пункту й були доступні лише з чужих * вкладок: правила алертів (/rules), відповідність конфігів * (/compliance) і профілі збору (/profiles). Сторінка без пункту — це * сторінка, про існування якої знає лише той, хто її писав. */ const navGroups: NavGroup[] = [ { id: 'monitoring', title: 'Моніторинг', items: [ { to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' }, { to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' }, { to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true }, // Трапи в «Моніторингу», а не в «Зборі даних»: це не налаштування // збору, а те, що прилетіло. Поруч з алертами навмисно — сюди // йдуть із питання «а що там ще сталося, про що ми не завели // тригер», і половина відповіді на нього — адреси, за якими хоста // немає взагалі. { to: '/traps', label: 'Трапи', icon: '📥', perm: 'devices:read' }, { to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' }, // SLA в «Моніторингу», а не в «Адмініструванні»: це підсумок того, // що показують решта пунктів цієї групи, і питають його ті самі // люди. Поруч із метриками навмисно — там дивляться, як мережа // почувається зараз, тут — як вона почувалась за квартал, і другий // погляд без першого не має сенсу. { to: '/sla', label: 'Звіти SLA', icon: '📋', perm: 'devices:read' }, ], }, { id: 'inventory', title: 'Інвентар', items: [ { to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' }, { to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' }, ], }, { id: 'configs', title: 'Конфігурації', items: [ // Підписи повторюють вкладки, які ці три сторінки вже малюють // одна на одній. Різні слова в панелі й на сторінці змусили б // здогадуватись, що це те саме місце. { to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' }, // Конфіги серверів окремим пунктом, а не вкладкою в «Конфігах»: // там дивляться архів заліза, а тут вирішують, яка картка є // машиною якого зонда. Різні предмети — різні місця. { to: '/server-files', label: 'Сервери', icon: '🗄', perm: 'ncm:read' }, // Профіль описує, ЯК знімати конфіг із конкретної моделі. Це // налаштування збору — але збору конфігів, а не метрик, і шукати // його поруч із шаблонами SNMP немає причин. { to: '/profiles', label: 'Профілі', icon: '🧩', perm: 'ncm:read' }, // Дзеркало — це той самий архів, але питання до нього інше: не // «що зібрано», а «чи є копія за межами цього сервера». Пункт // видно з ncm:read навмисно: відмову дзеркала має помітити той, // хто щодня дивиться на конфіги, а не лише власник кабінету. { to: '/mirror', label: 'Дзеркало', icon: '🪞', perm: 'ncm:read' }, // Відповідність читає той самий архів, але ставить до нього інше // питання: не «що там написано», а «чи написано те, що треба». { to: '/compliance', label: 'Відповідність', icon: '✔', perm: 'ncm:read' }, // Окремим пунктом, а не вкладкою всередині «Конфігів»: там усе // читає вже зібране, а тут людина йде на живе залізо. Право теж // окреме (ncm:exec), тож у більшості ролей пункт просто не // з'явиться. { to: '/commands', label: 'Команди', icon: '⌨', perm: 'ncm:exec' }, ], }, { id: 'collect', title: 'Збір даних', items: [ { to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' }, { to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' }, { to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' }, // Черги переїхали сюди з «Огляду». Питання «чи все встигає» // ставлять до збору, а полагодити його йдуть до зонда — тобто в // сусідній пункт. В «Огляді» вони стояли поруч із мапою, до якої // не мають стосунку. { to: '/queues', label: 'Черги', icon: '⏳', perm: 'agents:read' }, // Поруч із чергами навмисно: там питання «чи все встигає», тут — // «чи все вміщається». Обидва про здоров'я збору, і ставлять їх // ті самі люди в ті самі дні. В «Адмініструванні» сховище стояло // б поруч із ролями й журналом — тобто там, куди мережевий // інженер не заходить, а закінчується диск саме в нього. { to: '/storage', label: 'Сховище', icon: '💾', perm: 'devices:read' }, ], }, { id: 'notify', title: 'Сповіщення', items: [ // Тригер вирішує, коли виникає алерт; канал — куди про нього // повідомити. Обидва про сповіщення й обидва досі були заховані: // тригери — за вкладкою всередині алертів, канали — у «збірі». // // У коді й у базі це й далі `alr.rules`: перейменовувати таблицю // заради підпису — міняти те, що працює, заради того, що видно. // А от у вікні має стояти слово, яким це називає мережевий // інженер: тригер він шукає в шаблоні, тригер і має знайти тут. { to: '/rules', label: 'Тригери', icon: '⚖', perm: 'alerts:read' }, { to: '/channels', label: 'Канали', icon: '📨', perm: 'alerts:read' }, // Ескалації йдуть після каналів, бо драбина складається з // каналів: спершу є куди слати, потім — кому і в якому порядку. { to: '/escalations', label: 'Ескалації', icon: '🔺', perm: 'alerts:read' }, ], }, { id: 'admin', title: 'Адміністрування', items: [ // «Користувачі», а не «Команда»: на сторінці керують обліковими // записами, ролями й доступами. «Команда» — про людей, і шукати // там налаштування прав нікому не спаде. { to: '/team', label: 'Користувачі', icon: '👥', perm: 'users:read' }, // Ролі окремим пунктом одразу після користувачів: там відповідають // на «хто в системі», тут — на «що кому вільно». Друге питання // ставлять рідше, але саме до нього приходять із «чому він цього // не бачить», і шукати відповідь усередині картки людини марно — // роль спільна для всіх, хто в ній сидить. { to: '/roles', label: 'Ролі та права', icon: '🔐', perm: 'users:read' }, // Поруч із «Користувачами», бо це два боки одного питання: там // видають доступ, тут дивляться, як ним скористались. Право окреме // (audit:read) і є лише у власника й адміна, тож у решти ролей // пункт не з'явиться — а разом із ним і група, якщо в ній більше // нічого не лишилось. { to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' }, // Одразу під журналом аудиту, бо їх плутають, а різниця між ними // вирішальна: аудит відповідає на «хто що зробив», журнал сервера — // на «на що лається сама система». Поруч цю різницю видно з двох // підписів; порізно людина шукала б причину відмови в аудиті й не // знайшла б її ніколи. Право інше й ширше (settings:write), тому // пункт бачать не всі, хто бачить аудит. { to: '/server-log', label: 'Журнал сервера', icon: '📜', perm: 'settings:write' }, // Тариф останнім в «Адмініструванні», а не окремою групою: питання // «скільки в мене лишилось слотів» ставлять поруч із питанням // «кого ще завести», і обидва — тут. Окрема група з одного пункту // додала б заголовок, який нічого не групує. { to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' }, ], }, { // Окрема група з одного пункту — і це свідомий виняток із правила, // записаного вище («група з одного пункту додала б заголовок, // який нічого не групує»). // // Причина винятку: усі інші групи — про СИСТЕМУ, а ця про того, // хто на неї дивиться. Заголовок тут групує саме це — і саме через // цю різницю в роді профіль нікуди не вписався й лишився без пункту // взагалі — рівно той випадок, про який написано вище: // «сторінка, про існування якої знає лише той, хто її писав». // // Ціна була не косметична: тут живе прив'язка Telegram, без якої // кнопки під сповіщеннями відповідають «ваш Telegram не прив'язано». // За весь час цим не скористався ніхто — ні однієї прив'язки й жодного // згенерованого коду, включно з власником. // // Без perm: профіль є в кожного, хто увійшов. id: 'account', title: 'Обліковий запис', items: [{ to: '/profile', label: 'Мій профіль', icon: '👤' }], }, ] // --------------------------------------------------------------------- // Пам'ять налаштувань панелі // --------------------------------------------------------------------- /** Панель зведена до рейки зі значків. */ const RAIL_KEY = 'np.nav.collapsed' /** Перелік ЗГОРНУТИХ груп. */ const GROUPS_KEY = 'np.nav.groups' /** * Читання й запис налаштувань, які не валять застосунок. * * ПРИЧИНА: у приватному вікні Safari й за політики «блокувати всі * cookie» виняток кидає САМ доступ до `window.localStorage` — ще до * `getItem`. Незахищений виклик стоїть в ініціалізаторі стану, тобто * падає під час першого рендера оболонки: людина дістає білий екран * замість продукту й не має жодного способу зрозуміти чому. * НАСЛІДОК: втратити тут налаштування панелі — прийнятна ціна, * втратити застосунок — ні. */ function readSetting(key: string): string | null { try { return window.localStorage.getItem(key) } catch { return null } } function writeSetting(key: string, value: string): void { try { window.localStorage.setItem(key, value) } catch { // Немає де зберегти — панель просто працюватиме до перезавантаження. } } /** * Зберігаємо ЗГОРНУТІ групи, а не розгорнуті. * * ПРИЧИНА: у продукті майже тридцять сторінок і групи додаються далі. * Якби в пам'яті лежав перелік розгорнутих, кожна нова група приїжджала * б до всіх наявних користувачів згорнутою — тобто новий розділ побачив * би лише той, хто здогадався потикати заголовки. НАСЛІДОК: типовий * стан — «розгорнуто», і в пам'яті лежать лише свідомі відмови. * * Зіпсований або чужий вміст ключа — це порожня множина, а не виняток: * налаштування панелі не варте того, щоб через нього не відкрився * продукт. */ function readCollapsedGroups(): ReadonlySet { const raw = readSetting(GROUPS_KEY) if (!raw) return new Set() try { const parsed: unknown = JSON.parse(raw) if (!Array.isArray(parsed)) return new Set() return new Set(parsed.filter((v): v is string => typeof v === 'string')) } catch { return new Set() } } /** * Група, якій належить адреса. * * Збіг за межею сегмента, а не за голим `startsWith`: картка хоста живе * на `/devices/d-17`, і людина, яка на неї дивиться, лишається в * «Інвентарі». Гола перевірка префікса при цьому зарахувала б `/map` * і на адресу `/maps-archive`, якби така з'явилась, — і активною * підсвічувалась би чужа група. */ function groupOfPath(path: string): string | null { let bestID: string | null = null let bestLen = -1 for (const g of navGroups) { for (const i of g.items) { if ((path === i.to || path.startsWith(i.to + '/')) && i.to.length > bestLen) { bestID = g.id bestLen = i.to.length } } } return bestID } /** * Розгортає групу, у якій опинилась поточна сторінка, — і лише її. * * ПРИЧИНА: людина перейшла за посиланням із чату або з картки хоста й * потрапила в групу, яку колись згорнула. Без цього вона бачить меню, у * якому поточної сторінки немає взагалі, і не має способу зрозуміти, де * вона є. * * НАСЛІДОК і межа: чіпаємо РІВНО одну групу — ту, у якій сторінка. * Решта згорнутих лишаються згорнутими, бо їхній стан — свідомий вибір * під ширину монітора, а не наслідок того, куди сьогодні клацнули. * Повертаємо ту саму множину, коли міняти нічого: інакше кожен рендер * давав би новий об'єкт і нескінченне оновлення стану. */ function expandGroupOf(collapsed: ReadonlySet, path: string): ReadonlySet { const id = groupOfPath(path) if (!id || !collapsed.has(id)) return collapsed const next = new Set(collapsed) next.delete(id) return next } /** * `inert` на згорнутій групі. * * ПРИЧИНА: нульова висота ховає пункти від ока, але не від клавіатури. * Без цього Tab провалюється всередину згорнутої групи, і фокус * шість разів зникає в нікуди — людина без миші просто не може пройти * меню. `aria-hidden` прибирає пункти з дерева доступності, `inert` — * з обходу табом; потрібні обидва, вони про різні речі. * * Окремим об'єктом, а не пропсом: типи React 18 атрибута ще не знають, * браузери знають з 2022 року. */ const INERT = { inert: '' } as Record /** * Каркас застосунку: бічна навігація, шапка, місце під сторінку. * * Пункт, на який немає права, не показується взагалі. Пункт, який * завжди веде до 403, гірший за його відсутність: він обіцяє * можливість, якої немає, і змушує людину гадати, що вона зробила не так. */ export function AppShell() { const [navOpen, setNavOpen] = useState(false) // Згорнутий стан переживає перезавантаження: людина обирає його раз і // назавжди — під ширину свого монітора, а не під конкретну сторінку. const [collapsed, setCollapsed] = useState(() => readSetting(RAIL_KEY) === '1') useEffect(() => { writeSetting(RAIL_KEY, collapsed ? '1' : '0') }, [collapsed]) const location = useLocation() const me = session.me() const alerts = useAlerts() const connection = useSyncExternalStore(live.subscribeState, live.getState) const wide = useWideScreen() // Рейка зі значків — режим широкого екрана, і це не примха стилю: на // телефоні панель уже є шухлядою на всю ширину, і другий «вужчий» // вигляд усередині неї означав би підписи без заголовків груп. // Розкладку далі задає саме `rail`, а не `collapsed`: інакше людина, // яка згорнула панель на моніторі, наступного ранку відкриває меню з // телефона й не бачить у ньому жодної назви розділу. const rail = collapsed && wide // Згорнуті групи — та сама пам'ять, що й у рейки, але окремим ключем: // це два різні рішення людини, і скидання одного не має чіпати інше. // // Початкове значення вже враховує адресу, з якої почали: якби активну // групу розгортав useEffect, вона встигла б намалюватись згорнутою й // кожне відкриття сторінки починалося б із анімації — тієї самої, якої // ніхто не просив. const [collapsedGroups, setCollapsedGroups] = useState>(() => expandGroupOf(readCollapsedGroups(), location.pathname), ) // Те саме при переході всередині застосунку. Правка стану просто в // рендері (а не в ефекті) — той самий випадок: React домальовує другий // прохід до того, як щось потрапить в DOM, тож стрибка не видно. const [seenPath, setSeenPath] = useState(location.pathname) if (seenPath !== location.pathname) { setSeenPath(location.pathname) setCollapsedGroups((prev) => expandGroupOf(prev, location.pathname)) } useEffect(() => { writeSetting(GROUPS_KEY, JSON.stringify([...collapsedGroups])) }, [collapsedGroups]) // Одна кнопка — один перемикач: не вгадуємо намір, а міняємо рівно ту // групу, по заголовку якої натиснули. const toggleGroup = (id: string) => setCollapsedGroups((prev) => { const next = new Set(prev) if (!next.delete(id)) next.add(id) return next }) const activeGroup = groupOfPath(location.pathname) // Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам: // лічильник алертів у шапці має оновлюватись і тоді, коли людина // дивиться на список пристроїв або взагалі на профіль. useEffect(() => { live.start() return () => live.stop() }, []) // Перехід між сторінками закриває мобільну шухляду: інакше вона // лишається поверх щойно відкритої сторінки. useEffect(() => setNavOpen(false), [location.pathname]) // Група без жодного доступного пункту зникає цілком: заголовок над // порожнечею обіцяє розділ, якого для цієї ролі не існує. const groups = navGroups // Пункт без perm бачать усі, хто увійшов. .map((g) => ({ ...g, items: g.items.filter((i) => !i.perm || session.can(i.perm)) })) .filter((g) => g.items.length > 0) const hasAny = groups.some((g) => g.items.length > 0) return (
NetPulse
{/* Замерзлий інтерфейс виглядає точно так само, як справний. Тому стан з'єднання видно з будь-якої сторінки, а не лише з мапи. */} {connection === 'online' ? 'наживо' : connection === 'connecting' ? '…' : 'офлайн'} {alerts.counts.firing > 0 && ( 0 ? 'animate-pulse border-red-800 bg-red-950/60 text-red-200' : 'border-amber-800 bg-amber-950/50 text-amber-200' }`} title={`${alerts.counts.firing} ${plural(alerts.counts.firing, ['активний алерт', 'активні алерти', 'активних алертів'])}`} > 🔔 {alerts.counts.firing} )} {/* Вхід у профіль малюється ЗАВЖДИ, а не лише коли є чим підписати кнопку. Раніше умова вимагала імені або пошти, а /me віддавало лише пошту — порожню в облікового запису власника, який заводить установник і який входить за іменем. Тобто в типовій інсталяції єдиний шлях у профіль не малювався взагалі, і сторінка була досяжна лише введенням адреси вручну. */} {me?.userID && (
👤 {me?.username || me?.email || 'Профіль'}
)}
{navOpen && (
setNavOpen(false)} /> )}
) }