diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..58e80b1
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,13 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "netpulse-web",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev"],
+ "cwd": "web",
+ "port": 5173,
+ "url": "http://localhost:5173"
+ }
+ ]
+}
diff --git a/ROADMAP.md b/ROADMAP.md
index de0af33..359563c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -409,6 +409,83 @@ Diff* — усе з ТЗ.
- **SNMPv3-трапи не перевіряються** — розбираються й зберігаються, підпис
і шифрування не звіряються.
+## Етап 13. Випуск: реєстр образів, релізи, пакети — НЕ ЗРОБЛЕНО
+
+Поставлена задача, дослівно: «ми ж зможемо його встановлювати з пакетів?
+А то зараз не дуже».
+
+Зауваження справедливе. Сьогоднішній шлях клієнта — клонувати
+репозиторій, зібрати образи в себе (кілька хвилин і гігабайти) і
+запустити установник. Це шлях розробника: клієнт бачить вихідний код,
+якого не мав би бачити, і платить за збірку часом свого сервера.
+
+### Чому «пакет» неможливий просто зараз
+
+Не тому, що ліньки написати `.deb`. **Немає що в нього класти.** Образи
+збираються на машині клієнта з джерел, тобто продукт як артефакт не
+існує — існує лише рецепт. Пакет у такому стані ставив би те саме
+«зберіть самі», лише з гарнішою обгорткою.
+
+Тому порядок жорсткий: спершу реєстр, потім релізи, і лише потім пакети.
+
+### 13.1 Реєстр образів
+
+**Інфраструктура вже є:** Forgejo має вбудований реєстр контейнерів, а
+CI-раннер ми запустили 2026-08-27. Бракує лише того, щоб почати ним
+користуватись.
+
+Образи `netpulse/server` і `netpulse/agent` мають публікуватися туди з
+тегом версії. Після цього `docker compose` на машині клієнта тягне
+готове, а не збирає.
+
+### 13.2 Версіонування, якого немає
+
+`NETPULSE_VERSION=v0.1.0` — зараз просто рядок у `.env`. Тега в git
+немає, образу з таким тегом ніде, крім бойового стенду, немає теж.
+Зв'язку «версія → коміт → образ» не існує, тобто на питання «що саме у
+вас працює» відповіді немає.
+
+Потрібен тег у git як єдина точка істини, і збірка, що бере версію
+звідти, а не з рядка в конфігурації.
+
+### 13.3 Випускальний конвеєр
+
+ git tag v0.2.0 → CI збирає → штовхає образи в реєстр
+ → публікує netpulse-v0.2.0.tar.gz
+
+В архіві — `netpulse`, compose-файли, `netpulse.conf.example`. **Без
+джерел.** Плюс `install.sh`, який цей архів завантажує:
+
+ curl -fsSL https://git.zotac.keenetic.link/.../install.sh | sh
+
+І `netpulse upgrade` починає ходити в реєстр замість перезбірки.
+
+### 13.4 Пакети .deb / .rpm — кроком пізніше
+
+`netpulse` у `/usr/bin`, compose у `/opt/netpulse`, `netpulse.conf` у
+`/etc/netpulse`, systemd-юніт. Тоді працює те, чого й очікують:
+
+ apt install netpulse && netpulse install
+
+Робити це до 13.1 безглуздо з причини, названої вище.
+
+### Чого треба досягти ДО першого тегу
+
+**Оновлення з версії на версію ніхто не перевіряв.** Пісочниця
+установника (Етап 12) перевіряє установку з нуля — і саме вона знайшла
+дві вади, які чекали на першого клієнта. Але шляху «стояла 0.1, стала
+0.2» не перевіряє ніщо, а ламається найчастіше саме він: міграції
+поверх наявних даних, зміна конфігурації, несумісність зонда з новим
+колектором.
+
+Потрібен другий режим пісочниці: підняти попередню версію, налити в неї
+даних, оновити до нової, і прогнати ту саму самоперевірку. Без цього
+перший же реліз перевіряє себе на клієнтові.
+
+**Сумісність зонда й сервера.** Зонди стоять у мережах клієнтів і
+оновлюються не одночасно з сервером. Правило «який зонд працює з яким
+сервером» ніде не записане й не перевіряється.
+
## Порядок і чому саме такий
1. ~~**Етап 5 (користувачі)** — без входу продукт не можна віддати нікому.~~ ✅
diff --git a/docker-compose.yml b/docker-compose.yml
index 302332c..fd18f3d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -57,6 +57,36 @@ x-worker-dsn: &worker-dsn ${NETPULSE_WORKER_PASSWORD:+postgres://netpulse_worker
x-owner-dsn: &owner-dsn postgres://netpulse:${POSTGRES_PASSWORD:?потрібен POSTGRES_PASSWORD}@db:5432/netpulse?sslmode=disable
+# ---------------------------------------------------------------------
+# Журнали
+# ---------------------------------------------------------------------
+#
+# Служби пишуть у stdout, а не у файли всередині контейнера, і це не
+# спрощення. Файл у контейнері зникає при перестворенні, невидимий для
+# `docker logs`, вимагає власного тому — і ротацію довелось би писати
+# самим. Збирає, крутить і віддає назовні драйвер журналів; наша справа
+# — задати йому межі.
+#
+# І саме межі раніше задані НЕ БУЛИ. Типовий json-file без max-size не
+# крутить нічого: журнал росте, доки не з'їсть диск. На стенді за добу
+# роботи проксі набрав 27 МБ — тобто до біди були не роки, а місяці, і
+# першим би ліг Postgres, тобто весь продукт.
+#
+# 10 МБ × 3 файли на службу — стеля близько 250 МБ на всю інсталяцію.
+# Цього вистачає на кілька діб історії навіть на балакучому проксі.
+#
+# Кому потрібні справжні файли в /var/log або відправка в чужий
+# збирач — міняє драйвер однією змінною, не правлячи цей файл:
+# NETPULSE_LOG_DRIVER=journald (тоді журнали в journalctl)
+# NETPULSE_LOG_DRIVER=syslog (тоді туди, куди налаштований rsyslog)
+# Опції нижче json-file-специфічні, тож при зміні драйвера вони
+# ігноруються — це нормально й навмисно.
+x-logging: &logging
+ driver: ${NETPULSE_LOG_DRIVER:-json-file}
+ options:
+ max-size: ${NETPULSE_LOG_MAX_SIZE:-10m}
+ max-file: "${NETPULSE_LOG_MAX_FILES:-3}"
+
x-server-env: &server-env
NETPULSE_DSN: *app-dsn
NETPULSE_DSN_WORKER: *worker-dsn
@@ -66,6 +96,7 @@ x-server-env: &server-env
services:
db:
+ logging: *logging
image: timescale/timescaledb:2.17.2-pg16
environment:
POSTGRES_USER: netpulse
@@ -116,6 +147,7 @@ services:
restart: unless-stopped
cache:
+ logging: *logging
image: docker.dragonflydb.io/dragonflydb/dragonfly:v1.25.5
# memlock без обмеження прискорює Dragonfly, але дозволений не всюди:
# у контейнерній віртуалізації (LXC, частина VPS) ядро відмовляє, і
@@ -153,6 +185,7 @@ services:
# означало б гонку між ними. Тут же — один запуск, який мусить
# завершитись успіхом, перш ніж піднімуться API й колектор.
migrate:
+ logging: *logging
build: &server-build
context: .
dockerfile: deploy/Dockerfile.server
@@ -203,6 +236,7 @@ services:
# docker compose run --rm --entrypoint netpulse-user cli \
# -tenant default -login admin -role owner
cli:
+ logging: *logging
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
profiles: ["cli"]
@@ -218,6 +252,7 @@ services:
restart: "no"
api:
+ logging: *logging
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-api"]
@@ -250,6 +285,7 @@ services:
# -insecure. Порт назовні сам не публікує: до нього ходять через
# проксі, який має справжній сертифікат.
collector:
+ logging: *logging
build: *server-build
image: netpulse/server:${NETPULSE_VERSION:-dev}
entrypoint: ["netpulse-server"]
@@ -269,6 +305,7 @@ services:
restart: unless-stopped
proxy:
+ logging: *logging
image: caddy:2.8-alpine
environment:
NETPULSE_DOMAIN: ${NETPULSE_DOMAIN:?потрібен NETPULSE_DOMAIN}
@@ -306,6 +343,7 @@ services:
# інсталяції. Профіль, а не звичайна служба: у типовому розгортанні
# зонди стоять у мережах клієнтів, а не поруч із сервером.
agent:
+ logging: *logging
build:
context: .
dockerfile: deploy/Dockerfile.agent
diff --git a/netpulse b/netpulse
index b3b3a71..6d24f5a 100644
--- a/netpulse
+++ b/netpulse
@@ -1597,18 +1597,32 @@ selfcheck() {
# --- 3. Зонд дійшов до колектора ---------------------------------
# Реєстрація йде gRPC-каналом, якого HTTP-перевірки не бачать
# зовсім. Чекаємо, бо обмін запрошення на токен займає секунди.
+ # Хвилини мало. Зонд обмінює запрошення на токен при першому
+ # старті, а перший старт припадає на найзавантаженішу мить установки:
+ # щойно піднялись шість служб, а на слабкій машині ще й добігає
+ # збірка образів. Одного разу ця перевірка вже дала хибне червоне —
+ # зонд зареєструвався, просто пізніше.
+ #
+ # Хибне червоне тут коштує дорого: людина бачить «ЗУПИНКА» після
+ # успішної установки й починає розбирати те, що працює. Три хвилини
+ # чекання дешевші за годину пошуку неіснуючої поломки.
_i=0
_agents=""
- while [ "$_i" -lt 20 ]; do
+ while [ "$_i" -lt 60 ]; do
_agents=$(api_get /api/v1/agents)
json_nonempty "$_agents" && break
+ # Кожні півхвилини кажемо, що саме чекаємо: мовчазна пауза на три
+ # хвилини невідрізненна від зависання.
+ case "$_i" in
+ 10|20|30|40|50) say " чекаємо на реєстрацію зонда ($((_i * 3)) с)" ;;
+ esac
_i=$((_i + 1))
sleep 3
done
if json_nonempty "$_agents"; then
ok "зонд зареєструвався в колекторі"
else
- sc_fail "жоден зонд не зареєструвався за хвилину — колектор або запрошення"
+ sc_fail "жоден зонд не зареєструвався за три хвилини — колектор або запрошення"
fi
fi
diff --git a/server/internal/gitstore/branches_test.go b/server/internal/gitstore/branches_test.go
index ed4559a..1711228 100644
--- a/server/internal/gitstore/branches_test.go
+++ b/server/internal/gitstore/branches_test.go
@@ -29,6 +29,14 @@ func gitOut(t *testing.T, dir string, args ...string) string {
}
func TestDeleteBranchLocal(t *testing.T) {
+ // Сам gitstore працює на go-git і зовнішнього git не потребує — а
+ // от перевірка результату (`git branch --list`) потребує. Без цієї
+ // умови тест падає там, де git не встановлено: у голому
+ // golang:1.25-alpine, тобто саме в контейнері, де його й проганяють.
+ // Сусідній тест таку умову має, цей її не мав.
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("для звірки гілок потрібен git у PATH")
+ }
s := New(t.TempDir())
write(t, s, "device/sw-01-10.0.0.1", "sw-01/running.cfg", "hostname sw-01\n")
diff --git a/web/preview-nav.html b/web/preview-nav.html
new file mode 100644
index 0000000..e5a50d0
--- /dev/null
+++ b/web/preview-nav.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ NetPulse — огляд меню
+
+
+
+
+
+
diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx
index 661384d..61aba1b 100644
--- a/web/src/components/AppShell.tsx
+++ b/web/src/components/AppShell.tsx
@@ -5,7 +5,7 @@ import { session } from '../api/session'
import { live } from '../api/ws'
import { useAlerts } from '../hooks/useAlerts'
import type { Permission } from '../types'
-import { plural } from './ui'
+import { plural, useWideScreen } from './ui'
interface NavItem {
to: string
@@ -17,6 +17,16 @@ interface NavItem {
}
interface NavGroup {
+ /**
+ * Стійкий ключ групи — під ним живе згорнутий стан у localStorage.
+ *
+ * Окремо від `title` навмисно: підпис міняють заради людини (і в цьому
+ * файлі його вже міняли), а ключ не міняють ніколи. Якби ключем був
+ * заголовок, кожне переписування слова тихо скидало б налаштування
+ * всім, хто його вже зробив, — і виглядало б це як «меню саме
+ * розгорнулось», без жодної підказки чому.
+ */
+ id: string
title: string
items: NavItem[]
}
@@ -46,6 +56,7 @@ interface NavGroup {
*/
const navGroups: NavGroup[] = [
{
+ id: 'monitoring',
title: 'Моніторинг',
items: [
{ to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' },
@@ -67,6 +78,7 @@ const navGroups: NavGroup[] = [
],
},
{
+ id: 'inventory',
title: 'Інвентар',
items: [
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
@@ -74,6 +86,7 @@ const navGroups: NavGroup[] = [
],
},
{
+ id: 'configs',
title: 'Конфігурації',
items: [
// Підписи повторюють вкладки, які ці три сторінки вже малюють
@@ -104,6 +117,7 @@ const navGroups: NavGroup[] = [
],
},
{
+ id: 'collect',
title: 'Збір даних',
items: [
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
@@ -123,6 +137,7 @@ const navGroups: NavGroup[] = [
],
},
{
+ id: 'notify',
title: 'Сповіщення',
items: [
// Тригер вирішує, коли виникає алерт; канал — куди про нього
@@ -141,6 +156,7 @@ const navGroups: NavGroup[] = [
],
},
{
+ id: 'admin',
title: 'Адміністрування',
items: [
// «Користувачі», а не «Команда»: на сторінці керують обліковими
@@ -168,6 +184,126 @@ const navGroups: NavGroup[] = [
},
]
+// ---------------------------------------------------------------------
+// Пам'ять налаштувань панелі
+// ---------------------------------------------------------------------
+
+/** Панель зведена до рейки зі значків. */
+const RAIL_KEY = 'np.nav.collapsed'
+/** Перелік ЗГОРНУТИХ груп. */
+const GROUPS_KEY = 'np.nav.groups'
+
+/**
+ * Читання й запис налаштувань, які не валять застосунок.
+ *
+ * ПРИЧИНА: у приватному вікні Safari й за політики «блокувати всі
+ * cookie» виняток кидає САМ доступ до `window.localStorage` — ще до
+ * `getItem`. Незахищений виклик стоїть в ініціалізаторі стану, тобто
+ * падає під час першого рендера оболонки: людина дістає білий екран
+ * замість продукту й не має жодного способу зрозуміти чому.
+ * НАСЛІДОК: втратити тут налаштування панелі — прийнятна ціна,
+ * втратити застосунок — ні.
+ */
+function readSetting(key: string): string | null {
+ try {
+ return window.localStorage.getItem(key)
+ } catch {
+ return null
+ }
+}
+
+function writeSetting(key: string, value: string): void {
+ try {
+ window.localStorage.setItem(key, value)
+ } catch {
+ // Немає де зберегти — панель просто працюватиме до перезавантаження.
+ }
+}
+
+/**
+ * Зберігаємо ЗГОРНУТІ групи, а не розгорнуті.
+ *
+ * ПРИЧИНА: у продукті майже тридцять сторінок і групи додаються далі.
+ * Якби в пам'яті лежав перелік розгорнутих, кожна нова група приїжджала
+ * б до всіх наявних користувачів згорнутою — тобто новий розділ побачив
+ * би лише той, хто здогадався потикати заголовки. НАСЛІДОК: типовий
+ * стан — «розгорнуто», і в пам'яті лежать лише свідомі відмови.
+ *
+ * Зіпсований або чужий вміст ключа — це порожня множина, а не виняток:
+ * налаштування панелі не варте того, щоб через нього не відкрився
+ * продукт.
+ */
+function readCollapsedGroups(): ReadonlySet {
+ const raw = readSetting(GROUPS_KEY)
+ if (!raw) return new Set()
+ try {
+ const parsed: unknown = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return new Set()
+ return new Set(parsed.filter((v): v is string => typeof v === 'string'))
+ } catch {
+ return new Set()
+ }
+}
+
+/**
+ * Група, якій належить адреса.
+ *
+ * Збіг за межею сегмента, а не за голим `startsWith`: картка хоста живе
+ * на `/devices/d-17`, і людина, яка на неї дивиться, лишається в
+ * «Інвентарі». Гола перевірка префікса при цьому зарахувала б `/map`
+ * і на адресу `/maps-archive`, якби така з'явилась, — і активною
+ * підсвічувалась би чужа група.
+ */
+function groupOfPath(path: string): string | null {
+ let bestID: string | null = null
+ let bestLen = -1
+ for (const g of navGroups) {
+ for (const i of g.items) {
+ if ((path === i.to || path.startsWith(i.to + '/')) && i.to.length > bestLen) {
+ bestID = g.id
+ bestLen = i.to.length
+ }
+ }
+ }
+ return bestID
+}
+
+/**
+ * Розгортає групу, у якій опинилась поточна сторінка, — і лише її.
+ *
+ * ПРИЧИНА: людина перейшла за посиланням із чату або з картки хоста й
+ * потрапила в групу, яку колись згорнула. Без цього вона бачить меню, у
+ * якому поточної сторінки немає взагалі, і не має способу зрозуміти, де
+ * вона є.
+ *
+ * НАСЛІДОК і межа: чіпаємо РІВНО одну групу — ту, у якій сторінка.
+ * Решта згорнутих лишаються згорнутими, бо їхній стан — свідомий вибір
+ * під ширину монітора, а не наслідок того, куди сьогодні клацнули.
+ * Повертаємо ту саму множину, коли міняти нічого: інакше кожен рендер
+ * давав би новий об'єкт і нескінченне оновлення стану.
+ */
+function expandGroupOf(collapsed: ReadonlySet, path: string): ReadonlySet {
+ const id = groupOfPath(path)
+ if (!id || !collapsed.has(id)) return collapsed
+ const next = new Set(collapsed)
+ next.delete(id)
+ return next
+}
+
+/**
+ * `inert` на згорнутій групі.
+ *
+ * ПРИЧИНА: нульова висота ховає пункти від ока, але не від клавіатури.
+ * Без цього Tab провалюється всередину згорнутої групи, і фокус
+ * шість разів зникає в нікуди — людина без миші просто не може пройти
+ * меню. `aria-hidden` прибирає пункти з дерева доступності, `inert` —
+ * з обходу табом; потрібні обидва, вони про різні речі.
+ *
+ * Окремим об'єктом, а не пропсом: типи React 18 атрибута ще не знають,
+ * браузери знають з 2022 року.
+ */
+const INERT = { inert: '' } as Record
+
/**
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
*
@@ -180,16 +316,58 @@ export function AppShell() {
// Згорнутий стан переживає перезавантаження: людина обирає його раз і
// назавжди — під ширину свого монітора, а не під конкретну сторінку.
- const [collapsed, setCollapsed] = useState(
- () => localStorage.getItem('np.nav.collapsed') === '1',
- )
+ const [collapsed, setCollapsed] = useState(() => readSetting(RAIL_KEY) === '1')
useEffect(() => {
- localStorage.setItem('np.nav.collapsed', collapsed ? '1' : '0')
+ writeSetting(RAIL_KEY, collapsed ? '1' : '0')
}, [collapsed])
const location = useLocation()
const me = session.me()
const alerts = useAlerts()
const connection = useSyncExternalStore(live.subscribeState, live.getState)
+ const wide = useWideScreen()
+
+ // Рейка зі значків — режим широкого екрана, і це не примха стилю: на
+ // телефоні панель уже є шухлядою на всю ширину, і другий «вужчий»
+ // вигляд усередині неї означав би підписи без заголовків груп.
+ // Розкладку далі задає саме `rail`, а не `collapsed`: інакше людина,
+ // яка згорнула панель на моніторі, наступного ранку відкриває меню з
+ // телефона й не бачить у ньому жодної назви розділу.
+ const rail = collapsed && wide
+
+ // Згорнуті групи — та сама пам'ять, що й у рейки, але окремим ключем:
+ // це два різні рішення людини, і скидання одного не має чіпати інше.
+ //
+ // Початкове значення вже враховує адресу, з якої почали: якби активну
+ // групу розгортав useEffect, вона встигла б намалюватись згорнутою й
+ // кожне відкриття сторінки починалося б із анімації — тієї самої, якої
+ // ніхто не просив.
+ const [collapsedGroups, setCollapsedGroups] = useState>(() =>
+ expandGroupOf(readCollapsedGroups(), location.pathname),
+ )
+
+ // Те саме при переході всередині застосунку. Правка стану просто в
+ // рендері (а не в ефекті) — той самий випадок: React домальовує другий
+ // прохід до того, як щось потрапить в DOM, тож стрибка не видно.
+ const [seenPath, setSeenPath] = useState(location.pathname)
+ if (seenPath !== location.pathname) {
+ setSeenPath(location.pathname)
+ setCollapsedGroups((prev) => expandGroupOf(prev, location.pathname))
+ }
+
+ useEffect(() => {
+ writeSetting(GROUPS_KEY, JSON.stringify([...collapsedGroups]))
+ }, [collapsedGroups])
+
+ // Одна кнопка — один перемикач: не вгадуємо намір, а міняємо рівно ту
+ // групу, по заголовку якої натиснули.
+ const toggleGroup = (id: string) =>
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev)
+ if (!next.delete(id)) next.add(id)
+ return next
+ })
+
+ const activeGroup = groupOfPath(location.pathname)
// Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам:
// лічильник алертів у шапці має оновлюватись і тоді, коли людина
@@ -306,68 +484,163 @@ export function AppShell() {
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden
md:static md:z-0 md:translate-x-0 md:bg-slate-900/50
md:transition-[width]
- ${collapsed ? 'md:w-[3.75rem] md:px-1.5' : 'px-2 md:w-52'}
+ ${rail ? 'md:w-[3.75rem] md:px-1.5' : 'px-2 md:w-52'}
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
- {groups.map((g, gi) => (
-
- {/* Заголовок групи у згорнутому вигляді замінює риска:
- текст туди не влазить, а межа між групами потрібна —
- без неї значки зливаються в одну стрічку. */}
- {collapsed ? (
- gi > 0 &&
- ) : (
-
0 ? 'pt-3' : 'pt-1'}`}
- >
- {g.title}
-
- )}
-
- {g.items.map((i) => (
-
- `relative flex items-center rounded py-2.5 text-sm md:py-2 ${
- collapsed ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3'
- } ${
- isActive
- ? 'bg-slate-800 font-medium text-slate-100'
- : 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
- }`
- }
- >
- {/* Квадрат зі своїм центруванням, а не текст у вузькій
- коробці: значки — емодзі різної ширини, і покластися
- на text-align означає зсув на кожному другому. */}
- {
+ // У рейці групи не згортаються: там немає заголовка, а
+ // перемикач без підпису — загадка, а не керування. Значки
+ // видно всі, і це вже найкоротший можливий вигляд меню.
+ const open = rail || !collapsedGroups.has(g.id)
+ const panelID = `nav-group-${g.id}`
+ // Алерти рахуємо лише за тими пунктами, які їх показують:
+ // згорнута група не має ховати аварію. Решта груп у
+ // згорнутому вигляді показує, скільки в ній пунктів — щоб
+ // ціна розгортання була видна до кліку.
+ const firing = g.items.some((i) => i.badge) ? alerts.counts.firing : 0
+ return (
+
+ {/* Заголовок групи в рейці замінює риска: текст туди не
+ влазить, а межа між групами потрібна — без неї значки
+ зливаються в одну стрічку. */}
+ {rail ? (
+ gi > 0 &&
+ ) : (
+
+ )}
+
+ {/* Згортання через grid-template-rows, а не через
+ max-height: висота групи заздалегідь невідома (пункти
+ ховаються за правами), а вгадане «досить велике»
+ число робить анімацію тим ривкішою, чим менше в групі
+ пунктів. */}
+
+
+ {g.items.map((i) => (
+
+ `relative flex items-center rounded py-2.5 text-sm md:py-2 ${
+ rail ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3'
+ } ${
+ isActive
+ ? 'bg-slate-800 font-medium text-slate-100'
+ : 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
+ }`
+ }
+ >
+ {/* Квадрат зі своїм центруванням, а не текст у вузькій
+ коробці: значки — емодзі різної ширини, і покластися
+ на text-align означає зсув на кожному другому. */}
+
+ {i.icon}
+
+
+ {i.label}
+
+ {i.badge && alerts.counts.firing > 0 && (
+
+ {alerts.counts.firing}
+
+ )}
+
+ ))}
+
+
+
+ )
+ })}
{!hasAny && (
diff --git a/web/src/preview-nav.tsx b/web/src/preview-nav.tsx
new file mode 100644
index 0000000..8568b06
--- /dev/null
+++ b/web/src/preview-nav.tsx
@@ -0,0 +1,43 @@
+// ТИМЧАСОВИЙ стенд для огляду меню очима. Видаляється після перевірки.
+import { createRoot } from 'react-dom/client'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { AppShell } from './components/AppShell'
+import { session } from './api/session'
+import type { Permission } from './types'
+import './index.css'
+
+const counts = { firing: 3, acknowledged: 0, suppressed: 0, disaster: 1, high: 2 }
+
+window.fetch = (async (url: RequestInfo | URL) => {
+ const u = String(url)
+ const body = u.includes('/alerts') ? { alerts: [], counts } : {}
+ return {
+ status: 200,
+ ok: true,
+ statusText: '',
+ text: async () => JSON.stringify(body),
+ } as unknown as Response
+}) as typeof window.fetch
+
+class Silent {
+ close() {}
+ send() {}
+}
+;(window as unknown as { WebSocket: unknown }).WebSocket = Silent
+
+const perms = (new URLSearchParams(location.search).get('perms') ?? '*').split(
+ ',',
+) as Permission[]
+const path = new URLSearchParams(location.search).get('path') ?? '/devices'
+
+session.set('tok', { userID: 'u-1', username: 'admin', tenantID: 't-1', permissions: perms })
+
+createRoot(document.getElementById('root')!).render(
+
+
+ }>
+ сторінка
} />
+
+
+ ,
+)
diff --git a/web/src/test/navgroups.test.tsx b/web/src/test/navgroups.test.tsx
new file mode 100644
index 0000000..8e39cdc
--- /dev/null
+++ b/web/src/test/navgroups.test.tsx
@@ -0,0 +1,391 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'
+import { AppShell } from '../components/AppShell'
+import { session } from '../api/session'
+import { FakeWebSocket, fetchRouter } from './support'
+import type { Permission } from '../types'
+
+/**
+ * Групи бічного меню: `AppShell.tsx`.
+ *
+ * Меню — це те, що бачать на кожній сторінці продукту, і ламається воно
+ * тихо: `tsc` і збірка проходять і тоді, коли жоден пункт не
+ * відкривається, бо помилка тут не в типах, а в поведінці.
+ *
+ * Три поломки, які не видно з коду.
+ *
+ * 1. **Згорнута група ховає пункт назавжди.** Стан лежить у
+ * localStorage. Якщо автоматичне розгортання активної групи відпаде,
+ * людина, що прийшла за посиланням, побачить меню без сторінки, на
+ * якій вона стоїть, — і вирішить, що доступ забрали.
+ * 2. **Порожня група.** Пункти ховаються за правами. Заголовок над
+ * порожнечею обіцяє розділ, якого для цієї ролі не існує, — і людина
+ * йде питати, чому «Адміністрування» не відкривається.
+ * 3. **Схований пункт лишається досяжним.** Нульова висота ховає групу
+ * від ока, але не від Tab і не від зчитувача екрана: без
+ * `aria-hidden` фокус шість разів провалюється в невидиме.
+ *
+ * Чого тут немає й бути не може: анімації й рейки зі значків. Обидві
+ * живуть у CSS, а в jsdom стилів немає — перевіряти їх звідси означало
+ * б перевіряти рядок класу, а не те, що видно.
+ */
+
+const GROUPS_KEY = 'np.nav.groups'
+
+/** Відповідь `GET /api/v1/alerts` у формі, яку читає `useAlerts`. */
+function alerts(firing = 0) {
+ return {
+ alerts: [],
+ counts: { firing, acknowledged: 0, suppressed: 0, disaster: 0, high: firing },
+ }
+}
+
+/**
+ * Сторінка під оболонкою.
+ *
+ * Кнопка переходу потрібна саме тут: перевірити «перехід у згорнуту
+ * групу розгортає її» кліком по пункту меню неможливо — пункт у
+ * згорнутій групі якраз і недосяжний.
+ */
+function Probe() {
+ const navigate = useNavigate()
+ return (
+
+ )
+}
+
+function shell(path: string, permissions: Permission[] = ['*']) {
+ session.set('tok', { userID: 'u-1', username: 'me', tenantID: 't-1', permissions })
+ return render(
+
+
+ }>
+ } />
+
+
+ ,
+ )
+}
+
+/** Заголовок групи — кнопка, підпис якої починається з її назви. */
+function header(title: string) {
+ return screen.getByRole('button', { name: new RegExp('^' + title) })
+}
+
+beforeEach(() => {
+ FakeWebSocket.reset()
+ vi.stubGlobal('WebSocket', FakeWebSocket)
+ window.localStorage.clear()
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ window.localStorage.clear()
+ session.clear()
+})
+
+// ---------------------------------------------------------------------
+// Типовий стан і права
+// ---------------------------------------------------------------------
+
+describe('склад меню', () => {
+ it('типово всі групи розгорнуті', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/devices')
+
+ // Розгорнутих груп рівно стільки, скільки їх узагалі є: людина, яка
+ // відкрила продукт уперше, має побачити все, що їй доступне, а не
+ // шість закритих шухляд.
+ expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6)
+ expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument()
+ })
+
+ it('група, у якій права не лишили жодного пункту, не показується', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ // Мережевий інженер без ncm, alerts, users і audit.
+ shell('/devices', ['devices:read'])
+
+ // Половина перша: те, на що право є, — на місці.
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ expect(header('Інвентар')).toBeInTheDocument()
+ expect(header('Моніторинг')).toBeInTheDocument()
+ expect(header('Збір даних')).toBeInTheDocument()
+
+ // Половина друга, без якої перша нічого не доводить: групи, з яких
+ // права винесли все, зникли цілком — разом із заголовком.
+ expect(screen.queryByRole('button', { name: /^Конфігурації/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /^Сповіщення/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /^Адміністрування/ })).not.toBeInTheDocument()
+ expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(3)
+ })
+})
+
+// ---------------------------------------------------------------------
+// Згортання
+// ---------------------------------------------------------------------
+
+describe('згортання', () => {
+ it('клік по заголовку ховає пункти групи — і від ока, і від клавіатури', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/dashboard')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ fireEvent.click(header('Інвентар'))
+
+ expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'false')
+ // queryByRole не бачить того, що сховане від дерева доступності, —
+ // саме тому перевірка тут така, а не `toBeVisible`: висоту задає
+ // CSS, якого в jsdom немає, а `aria-hidden` задає розмітка.
+ expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: /^Групи/ })).not.toBeInTheDocument()
+
+ // Сусідня група не постраждала: перемикач один — група одна.
+ expect(screen.getByRole('link', { name: /Дашборд/ })).toBeInTheDocument()
+ })
+
+ it('повторний клік повертає пункти', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/dashboard')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ fireEvent.click(header('Інвентар'))
+ expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument()
+ fireEvent.click(header('Інвентар'))
+ expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ })
+
+ it('згорнута група показує, скільки в ній пунктів', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/dashboard')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ // Розгорнута група числа не показує: пункти й так перед очима.
+ expect(within(header('Інвентар')).queryByText('2')).not.toBeInTheDocument()
+
+ fireEvent.click(header('Інвентар'))
+ // Число — це ціна розгортання, видима до кліку: два пункти чи
+ // шість, вирішує людина, а не сюрприз після натискання.
+ expect(within(header('Інвентар')).getByText('2')).toBeInTheDocument()
+ })
+
+ it('згорнута група з активними алертами показує їх, а не кількість пунктів', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts(3) })
+ shell('/devices')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ fireEvent.click(header('Моніторинг'))
+
+ // У «Моніторингу» шість пунктів, але згорнута група має показувати
+ // не свій розмір, а те, що вимагає уваги: аварія, схована за
+ // згорнутим заголовком, — це рівно та поломка, заради якої меню й
+ // існує.
+ const h = header('Моніторинг')
+ expect(within(h).getByText('3')).toBeInTheDocument()
+ expect(within(h).queryByText('6')).not.toBeInTheDocument()
+
+ // А в групі без алертів число лишається кількістю пунктів.
+ fireEvent.click(header('Інвентар'))
+ expect(within(header('Інвентар')).getByText('2')).toBeInTheDocument()
+ })
+})
+
+// ---------------------------------------------------------------------
+// Пам'ять між заходами
+// ---------------------------------------------------------------------
+
+describe("пам'ять", () => {
+ it('згорнутий стан переживає перезавантаження сторінки', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ const first = shell('/dashboard')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ fireEvent.click(header('Інвентар'))
+ first.unmount()
+
+ // Друге монтування — це і є новий захід: стан має прийти з
+ // localStorage, а не з пам'яті попереднього дерева.
+ shell('/dashboard')
+ expect(await screen.findByRole('link', { name: /Дашборд/ })).toBeInTheDocument()
+ expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument()
+ })
+
+ it('у пам’яті лежать саме ЗГОРНУТІ групи', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/dashboard')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ fireEvent.click(header('Інвентар'))
+
+ // Перевірка не про формат заради формату: якби зберігались
+ // РОЗГОРНУТІ групи, кожна нова група приїхала б до наявних
+ // користувачів згорнутою й лишилась непоміченою.
+ expect(JSON.parse(window.localStorage.getItem(GROUPS_KEY) ?? '[]')).toEqual(['inventory'])
+ })
+
+ it('заблокований localStorage не валить меню', async () => {
+ // Приватне вікно й політика «блокувати всі cookie» кидають виняток
+ // на САМОМУ доступі до window.localStorage, ще до getItem. Це
+ // трапляється в першому ж рендері оболонки, тобто замість меню
+ // людина дістає білий екран.
+ const saved = Object.getOwnPropertyDescriptor(window, 'localStorage')
+ Object.defineProperty(window, 'localStorage', {
+ configurable: true,
+ get() {
+ throw new DOMException('The operation is insecure.')
+ },
+ })
+
+ try {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/devices')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ // Меню не лише малюється, а й далі працює — просто без пам'яті.
+ fireEvent.click(header('Інвентар'))
+ expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument()
+ } finally {
+ if (saved) Object.defineProperty(window, 'localStorage', saved)
+ else delete (window as unknown as Record).localStorage
+ }
+ })
+
+ it('сміття в ключі не заважає меню відкритись', async () => {
+ window.localStorage.setItem(GROUPS_KEY, '{зіпсовано')
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/devices')
+
+ expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6)
+ })
+})
+
+// ---------------------------------------------------------------------
+// Активна група
+// ---------------------------------------------------------------------
+
+describe('активна група', () => {
+ it('розгортається сама, а решта згорнутих лишається згорнутою', async () => {
+ // Усі шість згорнуті — так виглядає пам'ять людини, яка звузила
+ // меню до заголовків.
+ window.localStorage.setItem(
+ GROUPS_KEY,
+ JSON.stringify(['monitoring', 'inventory', 'configs', 'collect', 'notify', 'admin']),
+ )
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/devices')
+
+ // Половина перша: сторінку, на якій стоїмо, видно в меню.
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'true')
+
+ // Половина друга, без якої перша означала б «розгорнулось усе»:
+ // чужий вибір не затерто.
+ expect(header('Моніторинг')).toHaveAttribute('aria-expanded', 'false')
+ expect(header('Адміністрування')).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(1)
+ })
+
+ it('вкладена адреса лишається у своїй групі', async () => {
+ window.localStorage.setItem(GROUPS_KEY, JSON.stringify(['inventory']))
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ // Картка хоста — окрема адреса, але той самий розділ. Посилання на
+ // картку якраз і надсилають колезі в чат.
+ shell('/devices/d-17')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'true')
+ })
+
+ it('перехід у згорнуту групу розгортає ЛИШЕ її', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/devices')
+
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ // Свідомий вибір людини: дві групи згорнуто руками.
+ fireEvent.click(header('Моніторинг'))
+ fireEvent.click(header('Адміністрування'))
+
+ fireEvent.click(screen.getByRole('button', { name: 'перейти до алертів' }))
+
+ // Група, у яку прийшли, відкрилась — інакше меню показувало б усе,
+ // крім поточної сторінки.
+ expect(header('Моніторинг')).toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getByRole('link', { name: /Алерти/ })).toBeInTheDocument()
+ // А чужий вибір лишився чужим вибором.
+ expect(header('Адміністрування')).toHaveAttribute('aria-expanded', 'false')
+ })
+
+ it('згорнути активну групу все одно можна', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/devices')
+
+ // Автоматичне розгортання не має перетворювати заголовок на кнопку,
+ // яка не працює: людина натиснула — група згорнулась, і жоден
+ // наступний рендер її не відкрив назад.
+ expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
+ fireEvent.click(header('Інвентар'))
+ expect(header('Інвентар')).toHaveAttribute('aria-expanded', 'false')
+ expect(screen.queryByRole('link', { name: /Хости/ })).not.toBeInTheDocument()
+ })
+})
+
+// ---------------------------------------------------------------------
+// Доступність
+// ---------------------------------------------------------------------
+
+describe('доступність', () => {
+ it('заголовок групи — справжня кнопка, а не div зі слухачем', async () => {
+ fetchRouter({ 'GET /api/v1/alerts': alerts() })
+ shell('/dashboard')
+
+ const h = await screen.findByRole('button', { name: /^Інвентар/ })
+ // Enter і Пробіл на