Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не давала виходу. У базі нуль прив'язок і нуль кодів за весь час. Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як /me віддавало лише пошту. В облікового запису власника, який заводить установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій інсталяції входу в профіль не було взагалі. * /me віддає username (тип Me на фронтенді його вже вимагав); * вхід у профіль малюється завжди для людини; * пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим; * текст бота називає те, що видно на екрані; * сторінка каналів показує стан прив'язки біля telegram-каналу. Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму сходинку паралельно з першим. Тепер запис проходить лише за збігу токена; при розбіжності не відбувається нічого, сходинка лишається належною. 65 міграцій, усе зелене проти справжньої бази. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
729 lines
43 KiB
TypeScript
729 lines
43 KiB
TypeScript
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||
import { api } from '../api/client'
|
||
import { session } from '../api/session'
|
||
import { live } from '../api/ws'
|
||
import { useAlerts } from '../hooks/useAlerts'
|
||
import type { Permission } from '../types'
|
||
import { LogoMark } from './Logo'
|
||
import { plural, useWideScreen } from './ui'
|
||
|
||
interface NavItem {
|
||
to: string
|
||
label: string
|
||
icon: string
|
||
/**
|
||
* Право, без якого пункт не показується.
|
||
*
|
||
* Необов'язкове: є сторінки, які належать кожному, хто увійшов
|
||
* (власний профіль), і вигадувати для них право означало б брехати
|
||
* про модель доступу заради типу.
|
||
*/
|
||
perm?: Permission
|
||
/** Показувати лічильник алертів на цьому пункті. */
|
||
badge?: boolean
|
||
}
|
||
|
||
interface NavGroup {
|
||
/**
|
||
* Стійкий ключ групи — під ним живе згорнутий стан у localStorage.
|
||
*
|
||
* Окремо від `title` навмисно: підпис міняють заради людини (і в цьому
|
||
* файлі його вже міняли), а ключ не міняють ніколи. Якби ключем був
|
||
* заголовок, кожне переписування слова тихо скидало б налаштування
|
||
* всім, хто його вже зробив, — і виглядало б це як «меню саме
|
||
* розгорнулось», без жодної підказки чому.
|
||
*/
|
||
id: string
|
||
title: string
|
||
items: NavItem[]
|
||
}
|
||
|
||
/**
|
||
* Меню згруповане за ЖИТТЄВИМ ЦИКЛОМ даних, а не за темами.
|
||
*
|
||
* Порядок груп повторює шлях, яким дані проходять через систему:
|
||
*
|
||
* Моніторинг те, що вже зібрано й показане людині
|
||
* Інвентар з чого збирають
|
||
* Конфігурації окремий вид зібраного — текст конфігу
|
||
* Збір даних чим і як збирають
|
||
* Сповіщення що робити, коли зібране виглядає погано
|
||
* Адміністрування хто це все бачить
|
||
*
|
||
* Це не той самий поділ, що був: «Мережа» змішувала інвентар із
|
||
* конфігами, а «Налаштування збору» тримало канали сповіщень — тобто
|
||
* те, що збором не є взагалі. Перевірка проста: людина, яка шукає
|
||
* «куди приходить повідомлення про аварію», не має заходити в розділ
|
||
* про опитування пристроїв.
|
||
*
|
||
* Три сторінки взагалі не мали пункту й були доступні лише з чужих
|
||
* вкладок: правила алертів (/rules), відповідність конфігів
|
||
* (/compliance) і профілі збору (/profiles). Сторінка без пункту — це
|
||
* сторінка, про існування якої знає лише той, хто її писав.
|
||
*/
|
||
const navGroups: NavGroup[] = [
|
||
{
|
||
id: 'monitoring',
|
||
title: 'Моніторинг',
|
||
items: [
|
||
{ to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' },
|
||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||
// Трапи в «Моніторингу», а не в «Зборі даних»: це не налаштування
|
||
// збору, а те, що прилетіло. Поруч з алертами навмисно — сюди
|
||
// йдуть із питання «а що там ще сталося, про що ми не завели
|
||
// тригер», і половина відповіді на нього — адреси, за якими хоста
|
||
// немає взагалі.
|
||
{ to: '/traps', label: 'Трапи', icon: '📥', perm: 'devices:read' },
|
||
{ to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' },
|
||
// SLA в «Моніторингу», а не в «Адмініструванні»: це підсумок того,
|
||
// що показують решта пунктів цієї групи, і питають його ті самі
|
||
// люди. Поруч із метриками навмисно — там дивляться, як мережа
|
||
// почувається зараз, тут — як вона почувалась за квартал, і другий
|
||
// погляд без першого не має сенсу.
|
||
{ to: '/sla', label: 'Звіти SLA', icon: '📋', perm: 'devices:read' },
|
||
],
|
||
},
|
||
{
|
||
id: 'inventory',
|
||
title: 'Інвентар',
|
||
items: [
|
||
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
|
||
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
|
||
],
|
||
},
|
||
{
|
||
id: 'configs',
|
||
title: 'Конфігурації',
|
||
items: [
|
||
// Підписи повторюють вкладки, які ці три сторінки вже малюють
|
||
// одна на одній. Різні слова в панелі й на сторінці змусили б
|
||
// здогадуватись, що це те саме місце.
|
||
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm:read' },
|
||
// Конфіги серверів окремим пунктом, а не вкладкою в «Конфігах»:
|
||
// там дивляться архів заліза, а тут вирішують, яка картка є
|
||
// машиною якого зонда. Різні предмети — різні місця.
|
||
{ to: '/server-files', label: 'Сервери', icon: '🗄', perm: 'ncm:read' },
|
||
// Профіль описує, ЯК знімати конфіг із конкретної моделі. Це
|
||
// налаштування збору — але збору конфігів, а не метрик, і шукати
|
||
// його поруч із шаблонами SNMP немає причин.
|
||
{ to: '/profiles', label: 'Профілі', icon: '🧩', perm: 'ncm:read' },
|
||
// Дзеркало — це той самий архів, але питання до нього інше: не
|
||
// «що зібрано», а «чи є копія за межами цього сервера». Пункт
|
||
// видно з ncm:read навмисно: відмову дзеркала має помітити той,
|
||
// хто щодня дивиться на конфіги, а не лише власник кабінету.
|
||
{ to: '/mirror', label: 'Дзеркало', icon: '🪞', perm: 'ncm:read' },
|
||
// Відповідність читає той самий архів, але ставить до нього інше
|
||
// питання: не «що там написано», а «чи написано те, що треба».
|
||
{ to: '/compliance', label: 'Відповідність', icon: '✔', perm: 'ncm:read' },
|
||
// Окремим пунктом, а не вкладкою всередині «Конфігів»: там усе
|
||
// читає вже зібране, а тут людина йде на живе залізо. Право теж
|
||
// окреме (ncm:exec), тож у більшості ролей пункт просто не
|
||
// з'явиться.
|
||
{ to: '/commands', label: 'Команди', icon: '⌨', perm: 'ncm:exec' },
|
||
],
|
||
},
|
||
{
|
||
id: 'collect',
|
||
title: 'Збір даних',
|
||
items: [
|
||
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
|
||
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
|
||
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
|
||
// Черги переїхали сюди з «Огляду». Питання «чи все встигає»
|
||
// ставлять до збору, а полагодити його йдуть до зонда — тобто в
|
||
// сусідній пункт. В «Огляді» вони стояли поруч із мапою, до якої
|
||
// не мають стосунку.
|
||
{ to: '/queues', label: 'Черги', icon: '⏳', perm: 'agents:read' },
|
||
// Поруч із чергами навмисно: там питання «чи все встигає», тут —
|
||
// «чи все вміщається». Обидва про здоров'я збору, і ставлять їх
|
||
// ті самі люди в ті самі дні. В «Адмініструванні» сховище стояло
|
||
// б поруч із ролями й журналом — тобто там, куди мережевий
|
||
// інженер не заходить, а закінчується диск саме в нього.
|
||
{ to: '/storage', label: 'Сховище', icon: '💾', perm: 'devices:read' },
|
||
],
|
||
},
|
||
{
|
||
id: 'notify',
|
||
title: 'Сповіщення',
|
||
items: [
|
||
// Тригер вирішує, коли виникає алерт; канал — куди про нього
|
||
// повідомити. Обидва про сповіщення й обидва досі були заховані:
|
||
// тригери — за вкладкою всередині алертів, канали — у «збірі».
|
||
//
|
||
// У коді й у базі це й далі `alr.rules`: перейменовувати таблицю
|
||
// заради підпису — міняти те, що працює, заради того, що видно.
|
||
// А от у вікні має стояти слово, яким це називає мережевий
|
||
// інженер: тригер він шукає в шаблоні, тригер і має знайти тут.
|
||
{ to: '/rules', label: 'Тригери', icon: '⚖', perm: 'alerts:read' },
|
||
{ to: '/channels', label: 'Канали', icon: '📨', perm: 'alerts:read' },
|
||
// Ескалації йдуть після каналів, бо драбина складається з
|
||
// каналів: спершу є куди слати, потім — кому і в якому порядку.
|
||
{ to: '/escalations', label: 'Ескалації', icon: '🔺', perm: 'alerts:read' },
|
||
],
|
||
},
|
||
{
|
||
id: 'admin',
|
||
title: 'Адміністрування',
|
||
items: [
|
||
// «Користувачі», а не «Команда»: на сторінці керують обліковими
|
||
// записами, ролями й доступами. «Команда» — про людей, і шукати
|
||
// там налаштування прав нікому не спаде.
|
||
{ to: '/team', label: 'Користувачі', icon: '👥', perm: 'users:read' },
|
||
// Ролі окремим пунктом одразу після користувачів: там відповідають
|
||
// на «хто в системі», тут — на «що кому вільно». Друге питання
|
||
// ставлять рідше, але саме до нього приходять із «чому він цього
|
||
// не бачить», і шукати відповідь усередині картки людини марно —
|
||
// роль спільна для всіх, хто в ній сидить.
|
||
{ to: '/roles', label: 'Ролі та права', icon: '🔐', perm: 'users:read' },
|
||
// Поруч із «Користувачами», бо це два боки одного питання: там
|
||
// видають доступ, тут дивляться, як ним скористались. Право окреме
|
||
// (audit:read) і є лише у власника й адміна, тож у решти ролей
|
||
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
|
||
// нічого не лишилось.
|
||
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
|
||
// Одразу під журналом аудиту, бо їх плутають, а різниця між ними
|
||
// вирішальна: аудит відповідає на «хто що зробив», журнал сервера —
|
||
// на «на що лається сама система». Поруч цю різницю видно з двох
|
||
// підписів; порізно людина шукала б причину відмови в аудиті й не
|
||
// знайшла б її ніколи. Право інше й ширше (settings:write), тому
|
||
// пункт бачать не всі, хто бачить аудит.
|
||
{ to: '/server-log', label: 'Журнал сервера', icon: '📜', perm: 'settings:write' },
|
||
// Тариф останнім в «Адмініструванні», а не окремою групою: питання
|
||
// «скільки в мене лишилось слотів» ставлять поруч із питанням
|
||
// «кого ще завести», і обидва — тут. Окрема група з одного пункту
|
||
// додала б заголовок, який нічого не групує.
|
||
{ to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' },
|
||
],
|
||
},
|
||
{
|
||
// Окрема група з одного пункту — і це свідомий виняток із правила,
|
||
// записаного вище («група з одного пункту додала б заголовок,
|
||
// який нічого не групує»).
|
||
//
|
||
// Причина винятку: усі інші групи — про СИСТЕМУ, а ця про того,
|
||
// хто на неї дивиться. Заголовок тут групує саме це — і саме через
|
||
// цю різницю в роді профіль нікуди не вписався й лишився без пункту
|
||
// взагалі — рівно той випадок, про який написано вище:
|
||
// «сторінка, про існування якої знає лише той, хто її писав».
|
||
//
|
||
// Ціна була не косметична: тут живе прив'язка Telegram, без якої
|
||
// кнопки під сповіщеннями відповідають «ваш Telegram не прив'язано».
|
||
// За весь час цим не скористався ніхто — ні однієї прив'язки й жодного
|
||
// згенерованого коду, включно з власником.
|
||
//
|
||
// Без perm: профіль є в кожного, хто увійшов.
|
||
id: 'account',
|
||
title: 'Обліковий запис',
|
||
items: [{ to: '/profile', label: 'Мій профіль', icon: '👤' }],
|
||
},
|
||
]
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Пам'ять налаштувань панелі
|
||
// ---------------------------------------------------------------------
|
||
|
||
/** Панель зведена до рейки зі значків. */
|
||
const RAIL_KEY = 'np.nav.collapsed'
|
||
/** Перелік ЗГОРНУТИХ груп. */
|
||
const GROUPS_KEY = 'np.nav.groups'
|
||
|
||
/**
|
||
* Читання й запис налаштувань, які не валять застосунок.
|
||
*
|
||
* ПРИЧИНА: у приватному вікні Safari й за політики «блокувати всі
|
||
* cookie» виняток кидає САМ доступ до `window.localStorage` — ще до
|
||
* `getItem`. Незахищений виклик стоїть в ініціалізаторі стану, тобто
|
||
* падає під час першого рендера оболонки: людина дістає білий екран
|
||
* замість продукту й не має жодного способу зрозуміти чому.
|
||
* НАСЛІДОК: втратити тут налаштування панелі — прийнятна ціна,
|
||
* втратити застосунок — ні.
|
||
*/
|
||
function readSetting(key: string): string | null {
|
||
try {
|
||
return window.localStorage.getItem(key)
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function writeSetting(key: string, value: string): void {
|
||
try {
|
||
window.localStorage.setItem(key, value)
|
||
} catch {
|
||
// Немає де зберегти — панель просто працюватиме до перезавантаження.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Зберігаємо ЗГОРНУТІ групи, а не розгорнуті.
|
||
*
|
||
* ПРИЧИНА: у продукті майже тридцять сторінок і групи додаються далі.
|
||
* Якби в пам'яті лежав перелік розгорнутих, кожна нова група приїжджала
|
||
* б до всіх наявних користувачів згорнутою — тобто новий розділ побачив
|
||
* би лише той, хто здогадався потикати заголовки. НАСЛІДОК: типовий
|
||
* стан — «розгорнуто», і в пам'яті лежать лише свідомі відмови.
|
||
*
|
||
* Зіпсований або чужий вміст ключа — це порожня множина, а не виняток:
|
||
* налаштування панелі не варте того, щоб через нього не відкрився
|
||
* продукт.
|
||
*/
|
||
function readCollapsedGroups(): ReadonlySet<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>
|
||
|
||
/**
|
||
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
|
||
*
|
||
* Пункт, на який немає права, не показується взагалі. Пункт, який
|
||
* завжди веде до 403, гірший за його відсутність: він обіцяє
|
||
* можливість, якої немає, і змушує людину гадати, що вона зробила не так.
|
||
*/
|
||
export function AppShell() {
|
||
const [navOpen, setNavOpen] = useState(false)
|
||
|
||
// Згорнутий стан переживає перезавантаження: людина обирає його раз і
|
||
// назавжди — під ширину свого монітора, а не під конкретну сторінку.
|
||
const [collapsed, setCollapsed] = useState(() => readSetting(RAIL_KEY) === '1')
|
||
useEffect(() => {
|
||
writeSetting(RAIL_KEY, collapsed ? '1' : '0')
|
||
}, [collapsed])
|
||
const location = useLocation()
|
||
const me = session.me()
|
||
const alerts = useAlerts()
|
||
const connection = useSyncExternalStore(live.subscribeState, live.getState)
|
||
const wide = useWideScreen()
|
||
|
||
// Рейка зі значків — режим широкого екрана, і це не примха стилю: на
|
||
// телефоні панель уже є шухлядою на всю ширину, і другий «вужчий»
|
||
// вигляд усередині неї означав би підписи без заголовків груп.
|
||
// Розкладку далі задає саме `rail`, а не `collapsed`: інакше людина,
|
||
// яка згорнула панель на моніторі, наступного ранку відкриває меню з
|
||
// телефона й не бачить у ньому жодної назви розділу.
|
||
const rail = collapsed && wide
|
||
|
||
// Згорнуті групи — та сама пам'ять, що й у рейки, але окремим ключем:
|
||
// це два різні рішення людини, і скидання одного не має чіпати інше.
|
||
//
|
||
// Початкове значення вже враховує адресу, з якої почали: якби активну
|
||
// групу розгортав useEffect, вона встигла б намалюватись згорнутою й
|
||
// кожне відкриття сторінки починалося б із анімації — тієї самої, якої
|
||
// ніхто не просив.
|
||
const [collapsedGroups, setCollapsedGroups] = useState<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)
|
||
|
||
// Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам:
|
||
// лічильник алертів у шапці має оновлюватись і тоді, коли людина
|
||
// дивиться на список пристроїв або взагалі на профіль.
|
||
useEffect(() => {
|
||
live.start()
|
||
return () => live.stop()
|
||
}, [])
|
||
|
||
// Перехід між сторінками закриває мобільну шухляду: інакше вона
|
||
// лишається поверх щойно відкритої сторінки.
|
||
useEffect(() => setNavOpen(false), [location.pathname])
|
||
|
||
// Група без жодного доступного пункту зникає цілком: заголовок над
|
||
// порожнечею обіцяє розділ, якого для цієї ролі не існує.
|
||
const groups = navGroups
|
||
// Пункт без perm бачать усі, хто увійшов.
|
||
.map((g) => ({ ...g, items: g.items.filter((i) => !i.perm || session.can(i.perm)) }))
|
||
.filter((g) => g.items.length > 0)
|
||
const hasAny = groups.some((g) => g.items.length > 0)
|
||
|
||
return (
|
||
<div className="flex h-[100dvh] w-screen flex-col bg-slate-950 text-slate-200">
|
||
<header className="flex shrink-0 items-center gap-2 border-b border-slate-800 bg-slate-900 px-3 py-2">
|
||
<button
|
||
className="rounded border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm md:hidden"
|
||
onClick={() => setNavOpen((v) => !v)}
|
||
aria-label="Меню"
|
||
>
|
||
☰
|
||
</button>
|
||
|
||
<span className="flex items-center gap-2">
|
||
<LogoMark className="h-6 w-6 shrink-0 text-slate-100" />
|
||
<span className="text-base font-semibold tracking-tight text-slate-100">NetPulse</span>
|
||
</span>
|
||
|
||
<div className="ml-auto flex items-center gap-2 text-xs">
|
||
{/* Замерзлий інтерфейс виглядає точно так само, як справний.
|
||
Тому стан з'єднання видно з будь-якої сторінки, а не лише
|
||
з мапи. */}
|
||
<span
|
||
className="flex items-center gap-1.5 text-slate-500"
|
||
title={
|
||
connection === 'online'
|
||
? 'Дані оновлюються наживо'
|
||
: connection === 'connecting'
|
||
? 'Підключення…'
|
||
: "Зв'язок із сервером втрачено — дані на екрані застаріли"
|
||
}
|
||
>
|
||
<span
|
||
className={`h-1.5 w-1.5 rounded-full ${
|
||
connection === 'online'
|
||
? 'bg-emerald-400'
|
||
: connection === 'connecting'
|
||
? 'bg-amber-400'
|
||
: 'animate-pulse bg-red-500'
|
||
}`}
|
||
/>
|
||
<span className="hidden sm:inline">
|
||
{connection === 'online' ? 'наживо' : connection === 'connecting' ? '…' : 'офлайн'}
|
||
</span>
|
||
</span>
|
||
|
||
{alerts.counts.firing > 0 && (
|
||
<NavLink
|
||
to="/alerts"
|
||
className={`flex items-center gap-1.5 rounded border px-2 py-1 ${
|
||
alerts.counts.disaster + alerts.counts.high > 0
|
||
? 'animate-pulse border-red-800 bg-red-950/60 text-red-200'
|
||
: 'border-amber-800 bg-amber-950/50 text-amber-200'
|
||
}`}
|
||
title={`${alerts.counts.firing} ${plural(alerts.counts.firing, ['активний алерт', 'активні алерти', 'активних алертів'])}`}
|
||
>
|
||
🔔 <span className="tabular-nums">{alerts.counts.firing}</span>
|
||
</NavLink>
|
||
)}
|
||
|
||
{/*
|
||
Вхід у профіль малюється ЗАВЖДИ, а не лише коли є чим
|
||
підписати кнопку.
|
||
|
||
Раніше умова вимагала імені або пошти, а /me віддавало лише
|
||
пошту — порожню в облікового запису власника, який заводить
|
||
установник і який входить за іменем. Тобто в типовій
|
||
інсталяції єдиний шлях у профіль не малювався взагалі, і
|
||
сторінка була досяжна лише введенням адреси вручну.
|
||
*/}
|
||
{me?.userID && (
|
||
<div className="flex items-center gap-1">
|
||
<NavLink
|
||
to="/profile"
|
||
className="flex max-w-[10rem] items-center gap-1 truncate rounded border
|
||
border-slate-700 px-2 py-1 text-slate-300 hover:bg-slate-800"
|
||
title="Мій профіль"
|
||
>
|
||
<span aria-hidden>👤</span>
|
||
<span className="truncate">{me?.username || me?.email || 'Профіль'}</span>
|
||
</NavLink>
|
||
<button
|
||
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
|
||
onClick={() => void api.logout()}
|
||
title="Вийти"
|
||
>
|
||
⏻
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="relative flex min-h-0 flex-1">
|
||
{navOpen && (
|
||
<div
|
||
className="absolute inset-0 z-10 bg-slate-950/60 md:hidden"
|
||
onClick={() => setNavOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
<nav
|
||
// Смуга прокрутки забирає ширину справа й зсуває всю колонку
|
||
// значків уліво — у згорнутому вигляді це помітно одразу, бо
|
||
// порівнювати є з чим: рівний край панелі. Ховаємо смугу,
|
||
// прокрутка лишається колесом і клавіатурою.
|
||
className={`absolute inset-y-0 left-0 z-20 flex w-56 shrink-0 flex-col
|
||
overflow-y-auto overflow-x-hidden border-r border-slate-800
|
||
bg-slate-900 py-2 transition-transform
|
||
[scrollbar-width:none] [&::-webkit-scrollbar]:hidden
|
||
md:static md:z-0 md:translate-x-0 md:bg-slate-900/50
|
||
md:transition-[width]
|
||
${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) => {
|
||
// У рейці групи не згортаються: там немає заголовка, а
|
||
// перемикач без підпису — загадка, а не керування. Значки
|
||
// видно всі, і це вже найкоротший можливий вигляд меню.
|
||
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}
|
||
// На телефоні заголовок вищий, ніж на моніторі: там у
|
||
// нього цілять пальцем, а спільні 31 px — це промах
|
||
// через раз. Поруч пункти по 40 px, тож py-3 ще й
|
||
// вирівнює список.
|
||
//
|
||
// Фокус — 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'
|
||
}`}
|
||
>
|
||
{/* Той самий знак повертається, а не підмінюється
|
||
іншим: підміна читається як «щось блимнуло»,
|
||
поворот — як «це те саме, воно відкрилось».
|
||
|
||
▸ (U+25B8), а не ▶ (U+25B6): у другого є емодзійне
|
||
накреслення, і на Windows системний шрифт малює
|
||
його кольоровим прямокутником — у сірому
|
||
заголовку це виглядає як помилка. */}
|
||
<span
|
||
aria-hidden="true"
|
||
className={`inline-block text-[10px] leading-none transition-transform
|
||
duration-150 motion-reduce:transition-none
|
||
${open ? 'rotate-90' : ''}`}
|
||
>
|
||
▸
|
||
</span>
|
||
<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"
|
||
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">
|
||
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
|
||
</p>
|
||
)}
|
||
|
||
{/* Кнопка внизу, а не в шапці: згортання стосується самої
|
||
панелі, і шукати її перемикач в іншому кутку екрана —
|
||
зайвий крок. На телефоні панель і так шухляда. */}
|
||
<button
|
||
className={`mt-4 hidden shrink-0 items-center rounded py-2 text-sm
|
||
text-slate-500 hover:bg-slate-800/60 hover:text-slate-300 md:flex
|
||
${collapsed ? 'md:justify-center md:gap-0 md:px-0' : 'gap-2.5 px-3'}`}
|
||
onClick={() => setCollapsed((v) => !v)}
|
||
title={collapsed ? 'Розгорнути панель' : 'Згорнути панель'}
|
||
aria-label={collapsed ? 'Розгорнути панель' : 'Згорнути панель'}
|
||
>
|
||
<span className="flex h-5 w-5 shrink-0 items-center justify-center leading-none">
|
||
{collapsed ? '»' : '«'}
|
||
</span>
|
||
<span className={collapsed ? 'md:hidden' : ''}>Згорнути</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||
<Outlet context={alerts} />
|
||
</main>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|