Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду. Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список алертів, сітка хостів, текст), автооновлення з інтервалом дашборда, режим редагування. Права окремі від maps:* — дашборд збирає дані з усього тенанта. Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо фокус може стояти де завгодно; закриття за mousedown, а не click, щоб виділення тексту, доведене за межі вікна, не втрачало набране. Канали сповіщень редагуються (раніше лише створювались і видалялись). Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет зашифрований під видом старого протоколу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
8.1 KiB
TypeScript
189 lines
8.1 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: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' },
|
||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
|
||
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
|
||
{ to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' },
|
||
{ to: '/templates', label: 'Шаблони', icon: '📐', perm: 'devices:read' },
|
||
{ to: '/credentials', label: 'Доступи', icon: '🔑', perm: 'devices:read' },
|
||
{ to: '/configs', label: 'Конфіги', icon: '📄', perm: 'ncm: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?.username || 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.username}
|
||
>
|
||
{me.username || 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>
|
||
)
|
||
}
|