До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
2.4 KiB
TypeScript
92 lines
2.4 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { api } from '../api/client'
|
||
import { session } from '../api/session'
|
||
import { isAlertEvent, useLiveRefresh } from './useLiveRefresh'
|
||
import type { Alert, AlertCounts } from '../types'
|
||
|
||
const emptyCounts: AlertCounts = {
|
||
firing: 0,
|
||
acknowledged: 0,
|
||
suppressed: 0,
|
||
disaster: 0,
|
||
high: 0,
|
||
}
|
||
|
||
export interface LiveAlerts {
|
||
alerts: Alert[]
|
||
counts: AlertCounts
|
||
loading: boolean
|
||
error: string | null
|
||
reload: () => Promise<void>
|
||
ack: (id: string) => Promise<void>
|
||
close: (id: string) => Promise<void>
|
||
mute: (deviceID: string, minutes: number) => Promise<void>
|
||
}
|
||
|
||
/**
|
||
* Тримає список активних алертів.
|
||
*
|
||
* Подія по WebSocket не несе всього алерту — лише те, що щось
|
||
* змінилося. Список перечитується цілком: він короткий (десятки рядків),
|
||
* а склеювати його з дельт означало б розходитись зі станом сервера
|
||
* рівно в момент аварії, коли подій найбільше.
|
||
*/
|
||
export function useAlerts(): LiveAlerts {
|
||
const [alerts, setAlerts] = useState<Alert[]>([])
|
||
const [counts, setCounts] = useState<AlertCounts>(emptyCounts)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const canRead = session.can('alerts:read')
|
||
|
||
const reload = useCallback(async () => {
|
||
if (!canRead) {
|
||
setLoading(false)
|
||
return
|
||
}
|
||
try {
|
||
const r = await api.listAlerts()
|
||
setAlerts(r.alerts)
|
||
setCounts(r.counts)
|
||
setError(null)
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [canRead])
|
||
|
||
useEffect(() => {
|
||
void reload()
|
||
}, [reload])
|
||
|
||
useLiveRefresh(isAlertEvent, () => {
|
||
if (canRead) void reload()
|
||
})
|
||
|
||
const ack = useCallback(
|
||
async (id: string) => {
|
||
await api.ackAlert(id)
|
||
await reload()
|
||
},
|
||
[reload],
|
||
)
|
||
|
||
const close = useCallback(
|
||
async (id: string) => {
|
||
await api.closeAlert(id)
|
||
await reload()
|
||
},
|
||
[reload],
|
||
)
|
||
|
||
const mute = useCallback(
|
||
async (deviceID: string, minutes: number) => {
|
||
await api.muteDevice(deviceID, minutes)
|
||
await reload()
|
||
},
|
||
[reload],
|
||
)
|
||
|
||
return { alerts, counts, loading, error, reload, ack, close, mute }
|
||
}
|