diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..58e80b1 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "netpulse-web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "cwd": "web", + "port": 5173, + "url": "http://localhost:5173" + } + ] +} diff --git a/ROADMAP.md b/ROADMAP.md index de0af33..359563c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -409,6 +409,83 @@ Diff* — усе з ТЗ. - **SNMPv3-трапи не перевіряються** — розбираються й зберігаються, підпис і шифрування не звіряються. +## Етап 13. Випуск: реєстр образів, релізи, пакети — НЕ ЗРОБЛЕНО + +Поставлена задача, дослівно: «ми ж зможемо його встановлювати з пакетів? +А то зараз не дуже». + +Зауваження справедливе. Сьогоднішній шлях клієнта — клонувати +репозиторій, зібрати образи в себе (кілька хвилин і гігабайти) і +запустити установник. Це шлях розробника: клієнт бачить вихідний код, +якого не мав би бачити, і платить за збірку часом свого сервера. + +### Чому «пакет» неможливий просто зараз + +Не тому, що ліньки написати `.deb`. **Немає що в нього класти.** Образи +збираються на машині клієнта з джерел, тобто продукт як артефакт не +існує — існує лише рецепт. Пакет у такому стані ставив би те саме +«зберіть самі», лише з гарнішою обгорткою. + +Тому порядок жорсткий: спершу реєстр, потім релізи, і лише потім пакети. + +### 13.1 Реєстр образів + +**Інфраструктура вже є:** Forgejo має вбудований реєстр контейнерів, а +CI-раннер ми запустили 2026-08-27. Бракує лише того, щоб почати ним +користуватись. + +Образи `netpulse/server` і `netpulse/agent` мають публікуватися туди з +тегом версії. Після цього `docker compose` на машині клієнта тягне +готове, а не збирає. + +### 13.2 Версіонування, якого немає + +`NETPULSE_VERSION=v0.1.0` — зараз просто рядок у `.env`. Тега в git +немає, образу з таким тегом ніде, крім бойового стенду, немає теж. +Зв'язку «версія → коміт → образ» не існує, тобто на питання «що саме у +вас працює» відповіді немає. + +Потрібен тег у git як єдина точка істини, і збірка, що бере версію +звідти, а не з рядка в конфігурації. + +### 13.3 Випускальний конвеєр + + git tag v0.2.0 → CI збирає → штовхає образи в реєстр + → публікує netpulse-v0.2.0.tar.gz + +В архіві — `netpulse`, compose-файли, `netpulse.conf.example`. **Без +джерел.** Плюс `install.sh`, який цей архів завантажує: + + curl -fsSL https://git.zotac.keenetic.link/.../install.sh | sh + +І `netpulse upgrade` починає ходити в реєстр замість перезбірки. + +### 13.4 Пакети .deb / .rpm — кроком пізніше + +`netpulse` у `/usr/bin`, compose у `/opt/netpulse`, `netpulse.conf` у +`/etc/netpulse`, systemd-юніт. Тоді працює те, чого й очікують: + + apt install netpulse && netpulse install + +Робити це до 13.1 безглуздо з причини, названої вище. + +### Чого треба досягти ДО першого тегу + +**Оновлення з версії на версію ніхто не перевіряв.** Пісочниця +установника (Етап 12) перевіряє установку з нуля — і саме вона знайшла +дві вади, які чекали на першого клієнта. Але шляху «стояла 0.1, стала +0.2» не перевіряє ніщо, а ламається найчастіше саме він: міграції +поверх наявних даних, зміна конфігурації, несумісність зонда з новим +колектором. + +Потрібен другий режим пісочниці: підняти попередню версію, налити в неї +даних, оновити до нової, і прогнати ту саму самоперевірку. Без цього +перший же реліз перевіряє себе на клієнтові. + +**Сумісність зонда й сервера.** Зонди стоять у мережах клієнтів і +оновлюються не одночасно з сервером. Правило «який зонд працює з яким +сервером» ніде не записане й не перевіряється. + ## Порядок і чому саме такий 1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅ diff --git a/docker-compose.yml b/docker-compose.yml index 302332c..fd18f3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,36 @@ x-worker-dsn: &worker-dsn ${NETPULSE_WORKER_PASSWORD:+postgres://netpulse_worker x-owner-dsn: &owner-dsn postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable +# --------------------------------------------------------------------- +# Журнали +# --------------------------------------------------------------------- +# +# Служби пишуть у stdout, а не у файли всередині контейнера, і це не +# спрощення. Файл у контейнері зникає при перестворенні, невидимий для +# `docker logs`, вимагає власного тому — і ротацію довелось би писати +# самим. Збирає, крутить і віддає назовні драйвер журналів; наша справа +# — задати йому межі. +# +# І саме межі раніше задані НЕ БУЛИ. Типовий json-file без max-size не +# крутить нічого: журнал росте, доки не з'їсть диск. На стенді за добу +# роботи проксі набрав 27 МБ — тобто до біди були не роки, а місяці, і +# першим би ліг Postgres, тобто весь продукт. +# +# 10 МБ × 3 файли на службу — стеля близько 250 МБ на всю інсталяцію. +# Цього вистачає на кілька діб історії навіть на балакучому проксі. +# +# Кому потрібні справжні файли в /var/log або відправка в чужий +# збирач — міняє драйвер однією змінною, не правлячи цей файл: +# NETPULSE_LOG_DRIVER=journald (тоді журнали в journalctl) +# NETPULSE_LOG_DRIVER=syslog (тоді туди, куди налаштований rsyslog) +# Опції нижче json-file-специфічні, тож при зміні драйвера вони +# ігноруються — це нормально й навмисно. +x-logging: &logging + driver: ${NETPULSE_LOG_DRIVER:-json-file} + options: + max-size: ${NETPULSE_LOG_MAX_SIZE:-10m} + max-file: "${NETPULSE_LOG_MAX_FILES:-3}" + x-server-env: &server-env NETPULSE_DSN: *app-dsn NETPULSE_DSN_WORKER: *worker-dsn @@ -66,6 +96,7 @@ x-server-env: &server-env services: db: + logging: *logging image: timescale/timescaledb:2.17.2-pg16 environment: POSTGRES_USER: netpulse @@ -116,6 +147,7 @@ services: restart: unless-stopped cache: + logging: *logging image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5 # memlock без обмеження прискорює Dragonfly, але дозволений не всюди: # у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і @@ -153,6 +185,7 @@ services: # означало б гонку між ними. Тут же — один запуск, який мусить # завершитись успіхом, перш ніж піднімуться API й колектор. migrate: + logging: *logging build: &server-build context: . dockerfile: deploy/Dockerfile.server @@ -203,6 +236,7 @@ services: # docker compose run --rm --entrypoint netpulse-user cli \ # -tenant default -login admin -role owner cli: + logging: *logging build: *server-build image: netpulse/server:${NETPULSE_VERSION:-dev} profiles: ["cli"] @@ -218,6 +252,7 @@ services: restart: "no" api: + logging: *logging build: *server-build image: netpulse/server:${NETPULSE_VERSION:-dev} entrypoint: ["netpulse-api"] @@ -250,6 +285,7 @@ services: # -insecure. Порт назовні сам не публікує: до нього ходять через # проксі, який має справжній сертифікат. collector: + logging: *logging build: *server-build image: netpulse/server:${NETPULSE_VERSION:-dev} entrypoint: ["netpulse-server"] @@ -269,6 +305,7 @@ services: restart: unless-stopped proxy: + logging: *logging image: caddy:2.8-alpine environment: NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN} @@ -306,6 +343,7 @@ services: # інсталяції. Профіль, а не звичайна служба: у типовому розгортанні # зонди стоять у мережах клієнтів, а не поруч із сервером. agent: + logging: *logging build: context: . dockerfile: deploy/Dockerfile.agent diff --git a/netpulse b/netpulse index b3b3a71..6d24f5a 100644 --- a/netpulse +++ b/netpulse @@ -1597,18 +1597,32 @@ selfcheck() { # --- 3. Зонд дійшов до колектора --------------------------------- # Реєстрація йде gRPC-каналом, якого HTTP-перевірки не бачать # зовсім. Чекаємо, бо обмін запрошення на токен займає секунди. + # Хвилини мало. Зонд обмінює запрошення на токен при першому + # старті, а перший старт припадає на найзавантаженішу мить установки: + # щойно піднялись шість служб, а на слабкій машині ще й добігає + # збірка образів. Одного разу ця перевірка вже дала хибне червоне — + # зонд зареєструвався, просто пізніше. + # + # Хибне червоне тут коштує дорого: людина бачить «ЗУПИНКА» після + # успішної установки й починає розбирати те, що працює. Три хвилини + # чекання дешевші за годину пошуку неіснуючої поломки. _i=0 _agents="" - while [ "$_i" -lt 20 ]; do + while [ "$_i" -lt 60 ]; do _agents=$(api_get /api/v1/agents) json_nonempty "$_agents" && break + # Кожні півхвилини кажемо, що саме чекаємо: мовчазна пауза на три + # хвилини невідрізненна від зависання. + case "$_i" in + 10|20|30|40|50) say " чекаємо на реєстрацію зонда ($((_i * 3)) с)" ;; + esac _i=$((_i + 1)) sleep 3 done if json_nonempty "$_agents"; then ok "зонд зареєструвався в колекторі" else - sc_fail "жоден зонд не зареєструвався за хвилину — колектор або запрошення" + sc_fail "жоден зонд не зареєструвався за три хвилини — колектор або запрошення" fi fi diff --git a/server/internal/gitstore/branches_test.go b/server/internal/gitstore/branches_test.go index ed4559a..1711228 100644 --- a/server/internal/gitstore/branches_test.go +++ b/server/internal/gitstore/branches_test.go @@ -29,6 +29,14 @@ func gitOut(t *testing.T, dir string, args ...string) string { } func TestDeleteBranchLocal(t *testing.T) { + // Сам gitstore працює на go-git і зовнішнього git не потребує — а + // от перевірка результату (`git branch --list`) потребує. Без цієї + // умови тест падає там, де git не встановлено: у голому + // golang:1.25-alpine, тобто саме в контейнері, де його й проганяють. + // Сусідній тест таку умову має, цей її не мав. + if _, err := exec.LookPath("git"); err != nil { + t.Skip("для звірки гілок потрібен git у PATH") + } s := New(t.TempDir()) write(t, s, "device/sw-01-10.0.0.1", "sw-01/running.cfg", "hostname sw-01\n") diff --git a/web/preview-nav.html b/web/preview-nav.html new file mode 100644 index 0000000..e5a50d0 --- /dev/null +++ b/web/preview-nav.html @@ -0,0 +1,12 @@ + + + + + + NetPulse — огляд меню + + +
+ + + diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 661384d..61aba1b 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -5,7 +5,7 @@ import { session } from '../api/session' import { live } from '../api/ws' import { useAlerts } from '../hooks/useAlerts' import type { Permission } from '../types' -import { plural } from './ui' +import { plural, useWideScreen } from './ui' interface NavItem { to: string @@ -17,6 +17,16 @@ interface NavItem { } interface NavGroup { + /** + * Стійкий ключ групи — під ним живе згорнутий стан у localStorage. + * + * Окремо від `title` навмисно: підпис міняють заради людини (і в цьому + * файлі його вже міняли), а ключ не міняють ніколи. Якби ключем був + * заголовок, кожне переписування слова тихо скидало б налаштування + * всім, хто його вже зробив, — і виглядало б це як «меню саме + * розгорнулось», без жодної підказки чому. + */ + id: string title: string items: NavItem[] } @@ -46,6 +56,7 @@ interface NavGroup { */ const navGroups: NavGroup[] = [ { + id: 'monitoring', title: 'Моніторинг', items: [ { to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' }, @@ -67,6 +78,7 @@ const navGroups: NavGroup[] = [ ], }, { + id: 'inventory', title: 'Інвентар', items: [ { to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' }, @@ -74,6 +86,7 @@ const navGroups: NavGroup[] = [ ], }, { + id: 'configs', title: 'Конфігурації', items: [ // Підписи повторюють вкладки, які ці три сторінки вже малюють @@ -104,6 +117,7 @@ const navGroups: NavGroup[] = [ ], }, { + id: 'collect', title: 'Збір даних', items: [ { to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' }, @@ -123,6 +137,7 @@ const navGroups: NavGroup[] = [ ], }, { + id: 'notify', title: 'Сповіщення', items: [ // Тригер вирішує, коли виникає алерт; канал — куди про нього @@ -141,6 +156,7 @@ const navGroups: NavGroup[] = [ ], }, { + id: 'admin', title: 'Адміністрування', items: [ // «Користувачі», а не «Команда»: на сторінці керують обліковими @@ -168,6 +184,126 @@ const navGroups: NavGroup[] = [ }, ] +// --------------------------------------------------------------------- +// Пам'ять налаштувань панелі +// --------------------------------------------------------------------- + +/** Панель зведена до рейки зі значків. */ +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 + /** * Каркас застосунку: бічна навігація, шапка, місце під сторінку. * @@ -180,16 +316,58 @@ export function AppShell() { // Згорнутий стан переживає перезавантаження: людина обирає його раз і // назавжди — під ширину свого монітора, а не під конкретну сторінку. - const [collapsed, setCollapsed] = useState( - () => localStorage.getItem('np.nav.collapsed') === '1', - ) + const [collapsed, setCollapsed] = useState(() => readSetting(RAIL_KEY) === '1') useEffect(() => { - localStorage.setItem('np.nav.collapsed', collapsed ? '1' : '0') + 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) // Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам: // лічильник алертів у шапці має оновлюватись і тоді, коли людина @@ -306,68 +484,163 @@ export function AppShell() { [scrollbar-width:none] [&::-webkit-scrollbar]:hidden md:static md:z-0 md:translate-x-0 md:bg-slate-900/50 md:transition-[width] - ${collapsed ? 'md:w-[3.75rem] md:px-1.5' : 'px-2 md:w-52'} + ${rail ? 'md:w-[3.75rem] md:px-1.5' : 'px-2 md:w-52'} ${navOpen ? 'translate-x-0' : '-translate-x-full'}`} > - {groups.map((g, gi) => ( -
- {/* Заголовок групи у згорнутому вигляді замінює риска: - текст туди не влазить, а межа між групами потрібна — - без неї значки зливаються в одну стрічку. */} - {collapsed ? ( - gi > 0 &&
- ) : ( -
0 ? 'pt-3' : 'pt-1'}`} - > - {g.title} -
- )} - - {g.items.map((i) => ( - - `relative flex items-center rounded py-2.5 text-sm md:py-2 ${ - collapsed ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3' - } ${ - isActive - ? 'bg-slate-800 font-medium text-slate-100' - : 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200' - }` - } - > - {/* Квадрат зі своїм центруванням, а не текст у вузькій - коробці: значки — емодзі різної ширини, і покластися - на text-align означає зсув на кожному другому. */} - { + // У рейці групи не згортаються: там немає заголовка, а + // перемикач без підпису — загадка, а не керування. Значки + // видно всі, і це вже найкоротший можливий вигляд меню. + const open = rail || !collapsedGroups.has(g.id) + const panelID = `nav-group-${g.id}` + // Алерти рахуємо лише за тими пунктами, які їх показують: + // згорнута група не має ховати аварію. Решта груп у + // згорнутому вигляді показує, скільки в ній пунктів — щоб + // ціна розгортання була видна до кліку. + const firing = g.items.some((i) => i.badge) ? alerts.counts.firing : 0 + return ( +
+ {/* Заголовок групи в рейці замінює риска: текст туди не + влазить, а межа між групами потрібна — без неї значки + зливаються в одну стрічку. */} + {rail ? ( + gi > 0 &&
+ ) : ( + + )} + + {/* Згортання через grid-template-rows, а не через + max-height: висота групи заздалегідь невідома (пункти + ховаються за правами), а вгадане «досить велике» + число робить анімацію тим ривкішою, чим менше в групі + пунктів. */} +
+
+ {g.items.map((i) => ( + + `relative flex items-center rounded py-2.5 text-sm md:py-2 ${ + rail ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3' + } ${ + isActive + ? 'bg-slate-800 font-medium text-slate-100' + : 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200' + }` + } + > + {/* Квадрат зі своїм центруванням, а не текст у вузькій + коробці: значки — емодзі різної ширини, і покластися + на text-align означає зсув на кожному другому. */} + + {i.icon} + + + {i.label} + + {i.badge && alerts.counts.firing > 0 && ( + + {alerts.counts.firing} + + )} + + ))} +
+
+
+ ) + })} {!hasAny && (

diff --git a/web/src/preview-nav.tsx b/web/src/preview-nav.tsx new file mode 100644 index 0000000..8568b06 --- /dev/null +++ b/web/src/preview-nav.tsx @@ -0,0 +1,43 @@ +// ТИМЧАСОВИЙ стенд для огляду меню очима. Видаляється після перевірки. +import { createRoot } from 'react-dom/client' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { AppShell } from './components/AppShell' +import { session } from './api/session' +import type { Permission } from './types' +import './index.css' + +const counts = { firing: 3, acknowledged: 0, suppressed: 0, disaster: 1, high: 2 } + +window.fetch = (async (url: RequestInfo | URL) => { + const u = String(url) + const body = u.includes('/alerts') ? { alerts: [], counts } : {} + return { + status: 200, + ok: true, + statusText: '', + text: async () => JSON.stringify(body), + } as unknown as Response +}) as typeof window.fetch + +class Silent { + close() {} + send() {} +} +;(window as unknown as { WebSocket: unknown }).WebSocket = Silent + +const perms = (new URLSearchParams(location.search).get('perms') ?? '*').split( + ',', +) as Permission[] +const path = new URLSearchParams(location.search).get('path') ?? '/devices' + +session.set('tok', { userID: 'u-1', username: 'admin', tenantID: 't-1', permissions: perms }) + +createRoot(document.getElementById('root')!).render( + + + }> + сторінка

} /> + + + , +) diff --git a/web/src/test/navgroups.test.tsx b/web/src/test/navgroups.test.tsx new file mode 100644 index 0000000..8e39cdc --- /dev/null +++ b/web/src/test/navgroups.test.tsx @@ -0,0 +1,391 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom' +import { AppShell } from '../components/AppShell' +import { session } from '../api/session' +import { FakeWebSocket, fetchRouter } from './support' +import type { Permission } from '../types' + +/** + * Групи бічного меню: `AppShell.tsx`. + * + * Меню — це те, що бачать на кожній сторінці продукту, і ламається воно + * тихо: `tsc` і збірка проходять і тоді, коли жоден пункт не + * відкривається, бо помилка тут не в типах, а в поведінці. + * + * Три поломки, які не видно з коду. + * + * 1. **Згорнута група ховає пункт назавжди.** Стан лежить у + * localStorage. Якщо автоматичне розгортання активної групи відпаде, + * людина, що прийшла за посиланням, побачить меню без сторінки, на + * якій вона стоїть, — і вирішить, що доступ забрали. + * 2. **Порожня група.** Пункти ховаються за правами. Заголовок над + * порожнечею обіцяє розділ, якого для цієї ролі не існує, — і людина + * йде питати, чому «Адміністрування» не відкривається. + * 3. **Схований пункт лишається досяжним.** Нульова висота ховає групу + * від ока, але не від Tab і не від зчитувача екрана: без + * `aria-hidden` фокус шість разів провалюється в невидиме. + * + * Чого тут немає й бути не може: анімації й рейки зі значків. Обидві + * живуть у CSS, а в jsdom стилів немає — перевіряти їх звідси означало + * б перевіряти рядок класу, а не те, що видно. + */ + +const GROUPS_KEY = 'np.nav.groups' + +/** Відповідь `GET /api/v1/alerts` у формі, яку читає `useAlerts`. */ +function alerts(firing = 0) { + return { + alerts: [], + counts: { firing, acknowledged: 0, suppressed: 0, disaster: 0, high: firing }, + } +} + +/** + * Сторінка під оболонкою. + * + * Кнопка переходу потрібна саме тут: перевірити «перехід у згорнуту + * групу розгортає її» кліком по пункту меню неможливо — пункт у + * згорнутій групі якраз і недосяжний. + */ +function Probe() { + const navigate = useNavigate() + return ( + + ) +} + +function shell(path: string, permissions: Permission[] = ['*']) { + session.set('tok', { userID: 'u-1', username: 'me', tenantID: 't-1', permissions }) + return render( + + + }> + } /> + + + , + ) +} + +/** Заголовок групи — кнопка, підпис якої починається з її назви. */ +function header(title: string) { + return screen.getByRole('button', { name: new RegExp('^' + title) }) +} + +beforeEach(() => { + FakeWebSocket.reset() + vi.stubGlobal('WebSocket', FakeWebSocket) + window.localStorage.clear() +}) + +afterEach(() => { + vi.unstubAllGlobals() + window.localStorage.clear() + session.clear() +}) + +// --------------------------------------------------------------------- +// Типовий стан і права +// --------------------------------------------------------------------- + +describe('склад меню', () => { + it('типово всі групи розгорнуті', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/devices') + + // Розгорнутих груп рівно стільки, скільки їх узагалі є: людина, яка + // відкрила продукт уперше, має побачити все, що їй доступне, а не + // шість закритих шухляд. + expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6) + expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument() + }) + + it('група, у якій права не лишили жодного пункту, не показується', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + // Мережевий інженер без ncm, alerts, users і audit. + shell('/devices', ['devices:read']) + + // Половина перша: те, на що право є, — на місці. + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + expect(header('Інвентар')).toBeInTheDocument() + expect(header('Моніторинг')).toBeInTheDocument() + expect(header('Збір даних')).toBeInTheDocument() + + // Половина друга, без якої перша нічого не доводить: групи, з яких + // права винесли все, зникли цілком — разом із заголовком. + expect(screen.queryByRole('button', { name: /^Конфігурації/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Сповіщення/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^Адміністрування/ })).not.toBeInTheDocument() + expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(3) + }) +}) + +// --------------------------------------------------------------------- +// Згортання +// --------------------------------------------------------------------- + +describe('згортання', () => { + it('клік по заголовку ховає пункти групи — і від ока, і від клавіатури', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/dashboard') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + fireEvent.click(header('Інвентар')) + + expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'false') + // queryByRole не бачить того, що сховане від дерева доступності, — + // саме тому перевірка тут така, а не `toBeVisible`: висоту задає + // CSS, якого в jsdom немає, а `aria-hidden` задає розмітка. + expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: /^Групи/ })).not.toBeInTheDocument() + + // Сусідня група не постраждала: перемикач один — група одна. + expect(screen.getByRole('link', { name: /Дашборд/ })).toBeInTheDocument() + }) + + it('повторний клік повертає пункти', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/dashboard') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + fireEvent.click(header('Інвентар')) + expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument() + fireEvent.click(header('Інвентар')) + expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument() + }) + + it('згорнута група показує, скільки в ній пунктів', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/dashboard') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + // Розгорнута група числа не показує: пункти й так перед очима. + expect(within(header('Інвентар')).queryByText('2')).not.toBeInTheDocument() + + fireEvent.click(header('Інвентар')) + // Число — це ціна розгортання, видима до кліку: два пункти чи + // шість, вирішує людина, а не сюрприз після натискання. + expect(within(header('Інвентар')).getByText('2')).toBeInTheDocument() + }) + + it('згорнута група з активними алертами показує їх, а не кількість пунктів', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts(3) }) + shell('/devices') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + fireEvent.click(header('Моніторинг')) + + // У «Моніторингу» шість пунктів, але згорнута група має показувати + // не свій розмір, а те, що вимагає уваги: аварія, схована за + // згорнутим заголовком, — це рівно та поломка, заради якої меню й + // існує. + const h = header('Моніторинг') + expect(within(h).getByText('3')).toBeInTheDocument() + expect(within(h).queryByText('6')).not.toBeInTheDocument() + + // А в групі без алертів число лишається кількістю пунктів. + fireEvent.click(header('Інвентар')) + expect(within(header('Інвентар')).getByText('2')).toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------- +// Пам'ять між заходами +// --------------------------------------------------------------------- + +describe("пам'ять", () => { + it('згорнутий стан переживає перезавантаження сторінки', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + const first = shell('/dashboard') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + fireEvent.click(header('Інвентар')) + first.unmount() + + // Друге монтування — це і є новий захід: стан має прийти з + // localStorage, а не з пам'яті попереднього дерева. + shell('/dashboard') + expect(await screen.findByRole('link', { name: /Дашборд/ })).toBeInTheDocument() + expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument() + }) + + it('у пам’яті лежать саме ЗГОРНУТІ групи', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/dashboard') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + fireEvent.click(header('Інвентар')) + + // Перевірка не про формат заради формату: якби зберігались + // РОЗГОРНУТІ групи, кожна нова група приїхала б до наявних + // користувачів згорнутою й лишилась непоміченою. + expect(JSON.parse(window.localStorage.getItem(GROUPS_KEY) ?? '[]')).toEqual(['inventory']) + }) + + it('заблокований localStorage не валить меню', async () => { + // Приватне вікно й політика «блокувати всі cookie» кидають виняток + // на САМОМУ доступі до window.localStorage, ще до getItem. Це + // трапляється в першому ж рендері оболонки, тобто замість меню + // людина дістає білий екран. + const saved = Object.getOwnPropertyDescriptor(window, 'localStorage') + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new DOMException('The operation is insecure.') + }, + }) + + try { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/devices') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + // Меню не лише малюється, а й далі працює — просто без пам'яті. + fireEvent.click(header('Інвентар')) + expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument() + } finally { + if (saved) Object.defineProperty(window, 'localStorage', saved) + else delete (window as unknown as Record).localStorage + } + }) + + it('сміття в ключі не заважає меню відкритись', async () => { + window.localStorage.setItem(GROUPS_KEY, '{зіпсовано') + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/devices') + + expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6) + }) +}) + +// --------------------------------------------------------------------- +// Активна група +// --------------------------------------------------------------------- + +describe('активна група', () => { + it('розгортається сама, а решта згорнутих лишається згорнутою', async () => { + // Усі шість згорнуті — так виглядає пам'ять людини, яка звузила + // меню до заголовків. + window.localStorage.setItem( + GROUPS_KEY, + JSON.stringify(['monitoring', 'inventory', 'configs', 'collect', 'notify', 'admin']), + ) + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/devices') + + // Половина перша: сторінку, на якій стоїмо, видно в меню. + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'true') + + // Половина друга, без якої перша означала б «розгорнулось усе»: + // чужий вибір не затерто. + expect(header('Моніторинг')).toHaveAttribute('aria-expanded', 'false') + expect(header('Адміністрування')).toHaveAttribute('aria-expanded', 'false') + expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(1) + }) + + it('вкладена адреса лишається у своїй групі', async () => { + window.localStorage.setItem(GROUPS_KEY, JSON.stringify(['inventory'])) + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + // Картка хоста — окрема адреса, але той самий розділ. Посилання на + // картку якраз і надсилають колезі в чат. + shell('/devices/d-17') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'true') + }) + + it('перехід у згорнуту групу розгортає ЛИШЕ її', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/devices') + + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + // Свідомий вибір людини: дві групи згорнуто руками. + fireEvent.click(header('Моніторинг')) + fireEvent.click(header('Адміністрування')) + + fireEvent.click(screen.getByRole('button', { name: 'перейти до алертів' })) + + // Група, у яку прийшли, відкрилась — інакше меню показувало б усе, + // крім поточної сторінки. + expect(header('Моніторинг')).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('link', { name: /Алерти/ })).toBeInTheDocument() + // А чужий вибір лишився чужим вибором. + expect(header('Адміністрування')).toHaveAttribute('aria-expanded', 'false') + }) + + it('згорнути активну групу все одно можна', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/devices') + + // Автоматичне розгортання не має перетворювати заголовок на кнопку, + // яка не працює: людина натиснула — група згорнулась, і жоден + // наступний рендер її не відкрив назад. + expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument() + fireEvent.click(header('Інвентар')) + expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument() + }) +}) + +// --------------------------------------------------------------------- +// Доступність +// --------------------------------------------------------------------- + +describe('доступність', () => { + it('заголовок групи — справжня кнопка, а не div зі слухачем', async () => { + fetchRouter({ 'GET /api/v1/alerts': alerts() }) + shell('/dashboard') + + const h = await screen.findByRole('button', { name: /^Інвентар/ }) + // Enter і Пробіл на