Netpulse_SasS/web/src/pages/ChannelsPage.tsx
byrsapty 36f29e23ec Повноцінний веб: навігація і вісім сторінок
До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе,
що вміло API, доводилось викликати curl-ом.

Каркас:
- бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці,
  живий лічильник алертів
- пункт, на який немає права, не показується взагалі; пряме посилання
  лишається робочим і дає пояснення з назвою потрібного права
- стартова сторінка залежить від ролі: глядача без прав на мапи вітати
  відмовою — поганий перший екран

Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з
фільтрами, правила зі створенням через форму, канали з кнопкою
перевірки, зонди, команда з керуванням ролями, профіль зі зміною
пароля.

DataTable на телефоні перестає бути таблицею: горизонтальний скрол на
375 px робить дані формально присутніми й фактично нечитабельними.

Знайдено роботою з живим UI:
- WebSocket жив усередині мапи, тому на решті сторінок живих оновлень
  не було взагалі — лічильник алертів замерзав, щойно людина йшла з
  мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка
- сторінка пристроїв перечитувала все на кожну подію алерту; зі
  злиттям сплеску й спільними алертами той самий сплеск коштує
  2 запити замість дванадцяти
- канал із секретом показувався як «секрету немає»: прапорець ставився
  лише в гілці розшифровки, а перелік для UI викликається без ключа

Перевірено в браузері проти повного стека: усі вісім сторінок із
живими даними, створення правила й користувача через форми, перевірка
каналу, обмеження глядача, мобільний вигляд на 375 px.

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

379 lines
12 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 } from 'react'
import { api } from '../api/client'
import { session } from '../api/session'
import {
Button,
DataTable,
ErrorNote,
Field,
Modal,
PageHeader,
Spinner,
StatusBadge,
inputClass,
} from '../components/ui'
import type { Channel, Severity } from '../types'
const kindLabel: Record<string, string> = {
telegram: 'Telegram',
webhook: 'Webhook',
email: 'Email',
slack: 'Slack',
discord: 'Discord',
mattermost: 'Mattermost',
}
const severityLabel: Record<Severity, string> = {
info: 'Інформація і вище',
warning: 'Увага і вище',
average: 'Середня і вище',
high: 'Висока і вище',
disaster: 'Лише аварії',
}
export function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [testing, setTesting] = useState<string | null>(null)
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>(
null,
)
const canWrite = session.can('alerts:write')
const reload = async () => {
try {
setChannels(await api.listChannels())
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
void reload()
}, [])
return (
<>
<PageHeader
title="Канали сповіщень"
subtitle="Поки каналів немає, алерти видно лише в інтерфейсі"
actions={
canWrite && (
<Button kind="primary" onClick={() => setCreating(true)}>
+ Канал
</Button>
)
}
/>
<div className="min-h-0 flex-1 overflow-y-auto">
{error && (
<div className="p-4">
<ErrorNote>{error}</ErrorNote>
</div>
)}
{testResult && (
<div className="p-4 pb-0">
{testResult.ok ? (
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
Пробне повідомлення надіслано. Якщо воно не дійшло проблема на боці отримувача.
</div>
) : (
<ErrorNote>Не вдалося: {testResult.error}</ErrorNote>
)}
</div>
)}
{loading ? (
<Spinner />
) : (
<DataTable
rows={channels}
rowKey={(c) => c.id}
empty="Каналів ще немає"
columns={[
{
key: 'name',
header: 'Назва',
cell: (c) => <span className="font-medium text-slate-100">{c.name}</span>,
},
{ key: 'kind', header: 'Тип', cell: (c) => kindLabel[c.kind] ?? c.kind },
{
key: 'sev',
header: 'Поріг',
hideOnMobile: true,
cell: (c) => severityLabel[c.min_severity as Severity] ?? c.min_severity,
},
{
key: 'secret',
header: 'Секрет',
hideOnMobile: true,
cell: (c) =>
c.has_secret ? (
<span className="text-slate-500">збережено</span>
) : (
<span className="text-slate-600"></span>
),
},
{
key: 'state',
header: 'Стан',
cell: (c) => (
<StatusBadge
status={c.enabled ? 'up' : 'offline'}
label={c.enabled ? 'увімкнено' : 'вимкнено'}
/>
),
},
{
key: 'act',
header: 'Дії',
cell: (c) =>
canWrite && (
<div className="flex items-center gap-1.5">
<Button
disabled={testing === c.id}
onClick={async () => {
setTesting(c.id)
setTestResult(null)
try {
const r = await api.testChannel(c.id)
setTestResult({ id: c.id, ok: r.ok, error: r.error })
} catch (e) {
setTestResult({
id: c.id,
ok: false,
error: e instanceof Error ? e.message : String(e),
})
} finally {
setTesting(null)
}
}}
>
{testing === c.id ? '…' : 'Перевірити'}
</Button>
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Видалити канал разом із його секретом"
onClick={async () => {
await api.deleteChannel(c.id)
await reload()
}}
>
</button>
</div>
),
},
]}
/>
)}
</div>
{creating && (
<ChannelForm
onClose={() => setCreating(false)}
onSaved={async () => {
setCreating(false)
await reload()
}}
/>
)}
</>
)
}
function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
const [kind, setKind] = useState('telegram')
const [name, setName] = useState('')
const [minSeverity, setMinSeverity] = useState<Severity>('warning')
const [secret, setSecret] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
// Telegram
const [chatID, setChatID] = useState('')
// Webhook і похідні
const [url, setUrl] = useState('')
// Email
const [host, setHost] = useState('')
const [port, setPort] = useState('587')
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [user, setUser] = useState('')
const isWebhookish = ['webhook', 'slack', 'discord', 'mattermost'].includes(kind)
async function submit() {
setBusy(true)
setErr(null)
try {
let config: Record<string, unknown> = {}
if (kind === 'telegram') config = { chat_id: chatID }
else if (isWebhookish) config = { url, format: kind === 'webhook' ? 'netpulse' : 'text' }
else if (kind === 'email')
config = {
host,
port: Number(port) || 587,
from,
to: to
.split(',')
.map((x) => x.trim())
.filter(Boolean),
user,
}
await api.createChannel({
kind,
name: name.trim(),
config,
min_severity: minSeverity,
secret,
})
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Modal title="Новий канал" onClose={onClose}>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="Тип">
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
{Object.keys(kindLabel).map((k) => (
<option key={k} value={k}>
{kindLabel[k]}
</option>
))}
</select>
</Field>
<Field label="Надсилати від">
<select
className={inputClass}
value={minSeverity}
onChange={(e) => setMinSeverity(e.target.value as Severity)}
>
{(Object.keys(severityLabel) as Severity[]).map((s) => (
<option key={s} value={s}>
{severityLabel[s]}
</option>
))}
</select>
</Field>
</div>
<Field label="Назва">
<input
className={inputClass}
autoFocus
placeholder="Черговий інженер"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
{kind === 'telegram' && (
<>
<Field label="Chat ID" hint="Для груп — від’ємне число, наприклад -1001234567890">
<input className={inputClass} value={chatID} onChange={(e) => setChatID(e.target.value)} />
</Field>
<Field label="Токен бота" hint="Зберігається зашифрованим і більше ніколи не показується">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
</>
)}
{isWebhookish && (
<>
<Field
label="URL"
hint="Адреси у внутрішній мережі сервер відхиляє, якщо адміністратор не дозволив їх явно"
>
<input
className={inputClass}
placeholder="https://hooks.example.com/…"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</Field>
<Field label="Токен (необов’язково)" hint="Піде заголовком Authorization: Bearer">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
</>
)}
{kind === 'email' && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<Field label="SMTP-сервер">
<input className={inputClass} value={host} onChange={(e) => setHost(e.target.value)} />
</Field>
</div>
<Field label="Порт">
<input
className={inputClass}
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
/>
</Field>
</div>
<Field label="Від кого">
<input className={inputClass} value={from} onChange={(e) => setFrom(e.target.value)} />
</Field>
<Field label="Кому" hint="Кілька адрес — через кому">
<input className={inputClass} value={to} onChange={(e) => setTo(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Логін">
<input className={inputClass} value={user} onChange={(e) => setUser(e.target.value)} />
</Field>
<Field label="Пароль">
<input
className={inputClass}
type="password"
autoComplete="new-password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</Field>
</div>
</>
)}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button kind="primary" disabled={busy || !name.trim()} onClick={() => void submit()}>
{busy ? 'Збереження…' : 'Створити'}
</Button>
</div>
</div>
</Modal>
)
}