Стеля журналів, терплячіша самоперевірка, Етап 13 у плані
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 59s
CI / server (push) Successful in 1m13s
CI / agent (push) Successful in 2m54s

ЖУРНАЛИ БЕЗ СТЕЛІ. На запитання «чому не пишемо в /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:
byrsapty 2026-08-27 21:52:27 +03:00
parent c8b9d08c0b
commit 08801f631c
9 changed files with 932 additions and 63 deletions

13
.claude/launch.json Normal file
View 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"
}
]
}

View file

@ -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 (користувачі)** — без входу продукт не можна віддати нікому.~~

View file

@ -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

View file

@ -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

View file

@ -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
View 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>

View file

@ -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
View 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>,
)

View 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')
})
})