Стеля журналів, терплячіша самоперевірка, Етап 13 у плані
ЖУРНАЛИ БЕЗ СТЕЛІ. На запитання «чому не пишемо в /var/log» правильна відповідь — «бо в контейнері це не той шар»: файл усередині зникає при перестворенні, невидимий для docker logs і вимагає власної ротації. Але за питанням стояла справжня вада: драйвер json-file був налаштований порожньо, тобто НЕ КРУТИВ НІЧОГО. Проксі за добу набрав 27 МБ; до повного диска були місяці, і першим ліг би Postgres. Тепер 10 МБ × 3 файли на службу — близько 250 МБ на інсталяцію, і драйвер міняється однією змінною (journald, syslog) для тих, кому потрібні справжні файли або чужий збирач. САМОПЕРЕВІРКА ЗОНДА чекала хвилину й одного разу вже дала хибне червоне: зонд зареєструвався, просто пізніше — перший старт припадає на найзавантаженішу мить установки. Тепер три хвилини з повідомленням кожні півхвилини: мовчазна пауза невідрізненна від зависання, а хибне червоне після успішної установки коштує години пошуку неіснуючої поломки. GITSTORE: TestDeleteBranchLocal кличе зовнішній git і не мав перевірки на його відсутність — падав у golang:1.25-alpine, тобто саме там, де його проганяють. Сусідній тест таку перевірку має. ЕТАП 13 у ROADMAP: реєстр образів, релізи, пакети. Записано, чому порядок саме такий (пакет без реєстру ставив би «зберіть самі») і чого треба досягти до першого тегу — оновлення з версії на версію не перевіряв ніхто, а ламається найчастіше саме воно.
This commit is contained in:
parent
c8b9d08c0b
commit
08801f631c
9 changed files with 932 additions and 63 deletions
13
.claude/launch.json
Normal file
13
.claude/launch.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
77
ROADMAP.md
77
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 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
netpulse
18
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
12
web/preview-nav.html
Normal file
12
web/preview-nav.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="uk">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetPulse — огляд меню</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/preview-nav.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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<string> {
|
||||
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<string>, path: string): ReadonlySet<string> {
|
||||
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<string, string>
|
||||
|
||||
/**
|
||||
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
|
||||
*
|
||||
|
|
@ -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<ReadonlySet<string>>(() =>
|
||||
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) => (
|
||||
<div key={g.title} className="flex flex-col gap-0.5">
|
||||
{/* Заголовок групи у згорнутому вигляді замінює риска:
|
||||
текст туди не влазить, а межа між групами потрібна —
|
||||
без неї значки зливаються в одну стрічку. */}
|
||||
{collapsed ? (
|
||||
gi > 0 && <div className="mx-1 my-1.5 hidden border-t border-slate-800 md:block" />
|
||||
) : (
|
||||
<div
|
||||
className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider
|
||||
text-slate-600 ${gi > 0 ? 'pt-3' : 'pt-1'}`}
|
||||
>
|
||||
{g.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{g.items.map((i) => (
|
||||
<NavLink
|
||||
key={i.to}
|
||||
to={i.to}
|
||||
// Підказка лише у згорнутому вигляді: поруч із видимим
|
||||
// підписом вона повторювала б його й миготіла на кожному
|
||||
// проході мишею. Група в підказці теж не зайва — саме
|
||||
// вона зникла разом із заголовком.
|
||||
title={collapsed ? `${g.title} · ${i.label}` : undefined}
|
||||
className={({ isActive }) =>
|
||||
`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 означає зсув на кожному другому. */}
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center
|
||||
text-[15px] leading-none opacity-80"
|
||||
{groups.map((g, gi) => {
|
||||
// У рейці групи не згортаються: там немає заголовка, а
|
||||
// перемикач без підпису — загадка, а не керування. Значки
|
||||
// видно всі, і це вже найкоротший можливий вигляд меню.
|
||||
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 (
|
||||
<div key={g.id} className="flex flex-col">
|
||||
{/* Заголовок групи в рейці замінює риска: текст туди не
|
||||
влазить, а межа між групами потрібна — без неї значки
|
||||
зливаються в одну стрічку. */}
|
||||
{rail ? (
|
||||
gi > 0 && <div className="mx-1 my-1.5 border-t border-slate-800" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
// Саме <button>, а не <div> зі слухачем: Enter і
|
||||
// Пробіл на ньому — робота браузера, і жоден наш
|
||||
// обробник клавіш не розійдеться з тим, як це
|
||||
// працює в решті системи. Фокус теж не треба
|
||||
// вигадувати — він приходить разом з елементом.
|
||||
onClick={() => toggleGroup(g.id)}
|
||||
aria-expanded={open}
|
||||
aria-controls={panelID}
|
||||
// py-3 на телефоні, а не таке, як на моніторі: там у
|
||||
// заголовок цілять пальцем, і 26 px висоти — це
|
||||
// промах через раз. Поруч пункти по 40 px, тож
|
||||
// однакова висота ще й вирівнює список.
|
||||
//
|
||||
// Фокус — outline, а не ring: ring малюється тінню,
|
||||
// а тіні зникають у режимі високої контрастності
|
||||
// Windows — саме там, де рамку фокуса й шукають.
|
||||
// Зсув усередину, бо панель ріже все, що вилазить за
|
||||
// її край (overflow-x-hidden), і зовнішня рамка
|
||||
// втратила б праву сторону.
|
||||
className={`flex w-full items-center gap-1.5 rounded px-2 py-3 text-left
|
||||
text-[10px] font-semibold uppercase tracking-wider
|
||||
transition-colors hover:bg-slate-800/50
|
||||
focus-visible:outline-2 focus-visible:-outline-offset-2
|
||||
focus-visible:outline-slate-400 md:py-1.5
|
||||
${gi > 0 ? 'mt-2' : ''}
|
||||
${
|
||||
activeGroup === g.id
|
||||
? 'text-slate-400'
|
||||
: 'text-slate-600 hover:text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{i.icon}
|
||||
</span>
|
||||
<span className={`flex-1 truncate ${collapsed ? 'md:hidden' : ''}`}>
|
||||
{i.label}
|
||||
</span>
|
||||
{i.badge && alerts.counts.firing > 0 && (
|
||||
{/* Той самий знак повертається, а не підмінюється
|
||||
іншим: підміна читається як «щось блимнуло»,
|
||||
поворот — як «це те саме, воно відкрилось».
|
||||
|
||||
▸ (U+25B8), а не ▶ (U+25B6): у другого є емодзійне
|
||||
накреслення, і на Windows системний шрифт малює
|
||||
його кольоровим прямокутником — у сірому
|
||||
заголовку це виглядає як помилка. */}
|
||||
<span
|
||||
className={`rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200
|
||||
${collapsed ? 'md:absolute md:right-0.5 md:top-0.5 md:px-1' : ''}`}
|
||||
aria-hidden="true"
|
||||
className={`inline-block text-[10px] leading-none transition-transform
|
||||
duration-150 motion-reduce:transition-none
|
||||
${open ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{alerts.counts.firing}
|
||||
▸
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<span className="flex-1 truncate">{g.title}</span>
|
||||
{!open &&
|
||||
(firing > 0 ? (
|
||||
<span
|
||||
className="rounded bg-red-900/70 px-1.5 text-[10px] font-medium
|
||||
tabular-nums text-red-200"
|
||||
title={`${firing} ${plural(firing, ['активний алерт', 'активні алерти', 'активних алертів'])}`}
|
||||
>
|
||||
{firing}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="tabular-nums text-slate-700"
|
||||
title={`${g.items.length} ${plural(g.items.length, ['пункт', 'пункти', 'пунктів'])}`}
|
||||
>
|
||||
{g.items.length}
|
||||
</span>
|
||||
))}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Згортання через grid-template-rows, а не через
|
||||
max-height: висота групи заздалегідь невідома (пункти
|
||||
ховаються за правами), а вгадане «досить велике»
|
||||
число робить анімацію тим ривкішою, чим менше в групі
|
||||
пунктів. */}
|
||||
<div
|
||||
id={panelID}
|
||||
aria-hidden={!open}
|
||||
{...(open ? {} : INERT)}
|
||||
className={`grid transition-[grid-template-rows] duration-[180ms] ease-out
|
||||
motion-reduce:transition-none
|
||||
${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-0.5 overflow-hidden">
|
||||
{g.items.map((i) => (
|
||||
<NavLink
|
||||
key={i.to}
|
||||
to={i.to}
|
||||
// Підказка лише в рейці: поруч із видимим
|
||||
// підписом вона повторювала б його й миготіла на
|
||||
// кожному проході мишею. Група в підказці теж не
|
||||
// зайва — саме вона зникла разом із заголовком.
|
||||
title={rail ? `${g.title} · ${i.label}` : undefined}
|
||||
// Страхувальна сітка до `inert` вище: у рушіях,
|
||||
// старших за 2023 рік, атрибут не робить нічого,
|
||||
// і там Tab так само провалювався б у нульову
|
||||
// висоту. Ціна страховки — один атрибут.
|
||||
tabIndex={open ? undefined : -1}
|
||||
className={({ isActive }) =>
|
||||
`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 означає зсув на кожному другому. */}
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center
|
||||
text-[15px] leading-none opacity-80"
|
||||
>
|
||||
{i.icon}
|
||||
</span>
|
||||
<span className={`flex-1 truncate ${rail ? 'md:hidden' : ''}`}>
|
||||
{i.label}
|
||||
</span>
|
||||
{i.badge && alerts.counts.firing > 0 && (
|
||||
<span
|
||||
className={`rounded bg-red-900/70 px-1.5 text-[11px] tabular-nums text-red-200
|
||||
${rail ? 'md:absolute md:right-0.5 md:top-0.5 md:px-1' : ''}`}
|
||||
>
|
||||
{alerts.counts.firing}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!hasAny && (
|
||||
<p className="px-3 py-4 text-xs leading-relaxed text-slate-600">
|
||||
|
|
|
|||
43
web/src/preview-nav.tsx
Normal file
43
web/src/preview-nav.tsx
Normal file
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="*" element={<div className="p-4 text-slate-500">сторінка</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
391
web/src/test/navgroups.test.tsx
Normal file
391
web/src/test/navgroups.test.tsx
Normal file
|
|
@ -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 (
|
||||
<button type="button" onClick={() => navigate('/alerts')}>
|
||||
перейти до алертів
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function shell(path: string, permissions: Permission[] = ['*']) {
|
||||
session.set('tok', { userID: 'u-1', username: 'me', tenantID: 't-1', permissions })
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="*" element={<Probe />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
/** Заголовок групи — кнопка, підпис якої починається з її назви. */
|
||||
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<string, unknown>).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 і Пробіл на <button> — робота браузера. Перевіряємо саме
|
||||
// елемент, бо jsdom клавішу в клік не перетворює: тест на keyDown
|
||||
// тут доводив би лише те, що ми написали власний обробник — тобто
|
||||
// рівно те, чого робити не треба.
|
||||
expect(h.tagName).toBe('BUTTON')
|
||||
// Без type=button заголовок усередині форми надсилав би її.
|
||||
expect(h).toHaveAttribute('type', 'button')
|
||||
expect(h).not.toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
|
||||
it('aria-controls вказує на панель із пунктами, і панель ховається разом із групою', async () => {
|
||||
fetchRouter({ 'GET /api/v1/alerts': alerts() })
|
||||
shell('/dashboard')
|
||||
|
||||
const h = await screen.findByRole('button', { name: /^Інвентар/ })
|
||||
const panelID = h.getAttribute('aria-controls')
|
||||
expect(panelID).toBeTruthy()
|
||||
|
||||
const panel = document.getElementById(panelID ?? '')
|
||||
// Зв'язок має бути справжнім: aria-controls на неіснуючий
|
||||
// ідентифікатор — це обіцянка зчитувачу екрана, яку ніхто не
|
||||
// виконає, і жоден типізатор про неї не скаже.
|
||||
expect(panel).not.toBeNull()
|
||||
expect(panel).toContainElement(screen.getByRole('link', { name: /Хости/ }))
|
||||
expect(panel).toHaveAttribute('aria-hidden', 'false')
|
||||
|
||||
fireEvent.click(h)
|
||||
expect(panel).toHaveAttribute('aria-hidden', 'true')
|
||||
// inert прибирає групу з обходу табом там, де aria-hidden лише
|
||||
// мовчить для зчитувача.
|
||||
expect(panel).toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('пункти згорнутої групи не ловлять фокус', async () => {
|
||||
fetchRouter({ 'GET /api/v1/alerts': alerts() })
|
||||
shell('/dashboard')
|
||||
|
||||
const link = await screen.findByRole('link', { name: /Хости/ })
|
||||
expect(link).not.toHaveAttribute('tabindex')
|
||||
|
||||
fireEvent.click(header('Інвентар'))
|
||||
// Той самий вузол — уже без фокусу: шукаємо його повз дерево
|
||||
// доступності, бо звідти він щойно зник.
|
||||
expect(link).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue