Netpulse_SasS/web/src/components/AppShell.tsx
byrsapty 7fa8adc506 Дашборди, Esc/клік повз вікно, редагування каналів і протоколів
Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду.
Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список
алертів, сітка хостів, текст), автооновлення з інтервалом дашборда,
режим редагування. Права окремі від maps:* — дашборд збирає дані з
усього тенанта.

Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо
фокус може стояти де завгодно; закриття за mousedown, а не click, щоб
виділення тексту, доведене за межі вікна, не втрачало набране.

Канали сповіщень редагуються (раніше лише створювались і видалялись).
Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет
зашифрований під видом старого протоколу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 18:30:28 +03:00

189 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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