До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
183 lines
7.5 KiB
TypeScript
183 lines
7.5 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'
|
||
|
||
interface NavItem {
|
||
to: string
|
||
label: string
|
||
icon: string
|
||
perm: Permission
|
||
/** Показувати лічильник алертів на цьому пункті. */
|
||
badge?: boolean
|
||
}
|
||
|
||
const navItems: NavItem[] = [
|
||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||
{ to: '/devices', label: 'Пристрої', icon: '🖧', perm: 'devices:read' },
|
||
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
||
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
||
{ to: '/agents', label: 'Зонди', icon: '📡', perm: 'agents:read' },
|
||
{ to: '/team', label: 'Команда', icon: '👥', perm: 'users:read' },
|
||
]
|
||
|
||
/**
|
||
* Каркас застосунку: бічна навігація, шапка, місце під сторінку.
|
||
*
|
||
* Пункт, на який немає права, не показується взагалі. Пункт, який
|
||
* завжди веде до 403, гірший за його відсутність: він обіцяє
|
||
* можливість, якої немає, і змушує людину гадати, що вона зробила не так.
|
||
*/
|
||
export function AppShell() {
|
||
const [navOpen, setNavOpen] = useState(false)
|
||
const location = useLocation()
|
||
const me = session.me()
|
||
const alerts = useAlerts()
|
||
const connection = useSyncExternalStore(live.subscribeState, live.getState)
|
||
|
||
// Живе з'єднання належить оболонці, бо воно потрібне всім сторінкам:
|
||
// лічильник алертів у шапці має оновлюватись і тоді, коли людина
|
||
// дивиться на список пристроїв або взагалі на профіль.
|
||
useEffect(() => {
|
||
live.start()
|
||
return () => live.stop()
|
||
}, [])
|
||
|
||
// Перехід між сторінками закриває мобільну шухляду: інакше вона
|
||
// лишається поверх щойно відкритої сторінки.
|
||
useEffect(() => setNavOpen(false), [location.pathname])
|
||
|
||
const visible = navItems.filter((i) => session.can(i.perm))
|
||
|
||
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="text-base font-semibold tracking-tight text-slate-100">NetPulse</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} активних алертів`}
|
||
>
|
||
🔔 <span className="tabular-nums">{alerts.counts.firing}</span>
|
||
</NavLink>
|
||
)}
|
||
|
||
{me?.email && (
|
||
<div className="flex items-center gap-1">
|
||
<NavLink
|
||
to="/profile"
|
||
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
|
||
text-slate-400 hover:bg-slate-800"
|
||
title={me.email}
|
||
>
|
||
{me.email}
|
||
</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 gap-0.5
|
||
overflow-y-auto border-r border-slate-800 bg-slate-900 p-2
|
||
transition-transform
|
||
md:static md:z-0 md:w-52 md:translate-x-0 md:bg-slate-900/50
|
||
${navOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||
>
|
||
{visible.map((i) => (
|
||
<NavLink
|
||
key={i.to}
|
||
to={i.to}
|
||
className={({ isActive }) =>
|
||
`flex items-center gap-2.5 rounded px-3 py-2.5 text-sm md:py-2 ${
|
||
isActive
|
||
? 'bg-slate-800 font-medium text-slate-100'
|
||
: 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
|
||
}`
|
||
}
|
||
>
|
||
<span className="w-4 text-center opacity-80">{i.icon}</span>
|
||
<span className="flex-1">{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">
|
||
{alerts.counts.firing}
|
||
</span>
|
||
)}
|
||
</NavLink>
|
||
))}
|
||
|
||
{visible.length === 0 && (
|
||
<p className="px-3 py-4 text-xs leading-relaxed text-slate-600">
|
||
Вашій ролі не надано жодного розділу. Зверніться до власника організації.
|
||
</p>
|
||
)}
|
||
</nav>
|
||
|
||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||
<Outlet context={alerts} />
|
||
</main>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|