Netpulse_SasS/web/src/components/AppShell.tsx
byrsapty cd8d4c62ed Історія метрик, шаблони будь-яких перевірок, маршрути правил
Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.

Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.

Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.

Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.

Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.

Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.

Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:21:50 +03:00

187 lines
7.9 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: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
{ to: '/groups', 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>
)
}