Метрики збиралися в 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>
116 lines
5.5 KiB
TypeScript
116 lines
5.5 KiB
TypeScript
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>
|
||
)
|
||
}
|