До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|