Netpulse_SasS/web/src/App.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

116 lines
5.5 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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { api, staticTokenPresent } from './api/client'
import { session } from './api/session'
import { AppShell } from './components/AppShell'
import { LoginPage } from './components/LoginPage'
import { MapPage } from './pages/MapPage'
import { DevicesPage } from './pages/DevicesPage'
import { GroupsPage } from './pages/GroupsPage'
import { TemplatesPage } from './pages/TemplatesPage'
import { CredentialsPage } from './pages/CredentialsPage'
import { ConfigsPage } from './pages/ConfigsPage'
import { AlertsPage } from './pages/AlertsPage'
import { RulesPage } from './pages/RulesPage'
import { ChannelsPage } from './pages/ChannelsPage'
import { AgentsPage } from './pages/AgentsPage'
import { TeamPage } from './pages/TeamPage'
import { ProfilePage } from './pages/ProfilePage'
import type { Permission } from './types'
export default function App() {
// Сесія живе поза React, бо її читає й шар API. useSyncExternalStore
// підписує компонент на неї без дублювання стану.
const me = useSyncExternalStore(session.subscribe, session.me)
const [booting, setBooting] = useState(true)
// Після перезавантаження сторінки access-токен зник разом із памʼяттю
// вкладки, але httpOnly-кукі жива — пробуємо тихо відновити сесію.
useEffect(() => {
if (staticTokenPresent) {
setBooting(false)
return
}
void api.restore().finally(() => setBooting(false))
}, [])
if (booting) {
return (
<div className="flex h-[100dvh] items-center justify-center bg-slate-950 text-sm text-slate-600">
Відновлення сесії
</div>
)
}
if (!me && !staticTokenPresent) {
return <LoginPage onDone={() => setBooting(false)} />
}
return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Home />} />
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
<Route path="/credentials" element={<Guard perm="devices:read" element={<CredentialsPage />} />} />
<Route path="/templates" element={<Guard perm="devices:read" element={<TemplatesPage />} />} />
<Route path="/configs" element={<Guard perm="ncm:read" element={<ConfigsPage />} />} />
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
<Route path="/agents" element={<Guard perm="agents:read" element={<AgentsPage />} />} />
<Route path="/team" element={<Guard perm="users:read" element={<TeamPage />} />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="*" element={<Home />} />
</Route>
</Routes>
</BrowserRouter>
)
}
/**
* Стартова сторінка залежить від ролі.
*
* Глядача без права на мапи слати на «/map» означало б вітати його
* порожнім екраном із відмовою. Тому перший доступний розділ і стає
* домівкою.
*/
function Home() {
const order: { perm: Permission; to: string }[] = [
{ perm: 'maps:read', to: '/map' },
{ perm: 'alerts:read', to: '/alerts' },
{ perm: 'devices:read', to: '/devices' },
{ perm: 'ncm:read', to: '/configs' },
{ perm: 'agents:read', to: '/agents' },
{ perm: 'users:read', to: '/team' },
]
const first = order.find((o) => session.can(o.perm))
return <Navigate to={first?.to ?? '/profile'} replace />
}
/**
* Захист маршруту правом.
*
* Навігація вже ховає недоступні пункти, але посилання лишається
* робочим: його можна зберегти в закладки, отримати від колеги або
* успадкувати після зміни ролі. Сервер у будь-якому разі відповість 403 —
* питання лише в тому, побачить людина зрозуміле пояснення чи порожню
* сторінку з помилкою в консолі.
*/
function Guard({ perm, element }: { perm: Permission; element: React.ReactNode }) {
if (session.can(perm)) return <>{element}</>
return (
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-sm text-center">
<div className="mb-2 text-2xl">🔒</div>
<h2 className="text-sm font-medium text-slate-200">Розділ недоступний</h2>
<p className="mt-1 text-xs leading-relaxed text-slate-500">
Вашій ролі не надано права <code className="text-slate-400">{perm}</code>. Попросіть
власника організації змінити роль, якщо доступ потрібен для роботи.
</p>
</div>
</div>
)
}