fix(alerts): отличать неизвестные данные от пустого списка правил

This commit is contained in:
Denozordec committed 2026-10-03 17:30:46 +07:00
1 parent 9bc3a5db77
commit 3710fc6aab
5 files changed
+54 -29

No files matched your search

+35 -27
View File
@@ -39,6 +39,7 @@ import {
} from "lucide-react"
import { Flag, countryName } from "@/components/flag"
import { cn } from "@/lib/utils"
import {getAlerts} from "@/shared/api/alerts"
import { requestJson } from "@/shared/api/http-client"
import { toast } from "sonner"
@@ -2351,18 +2352,25 @@ function AddRuleSheet({
type SeverityFilter = AlertSeverity | "all"
export default function AlertsPage() {
export default function AlertsPage(){const {mode,backendUrl}=useDataSource();return <AlertsContent key={JSON.stringify([mode,backendUrl])}/>}
function AlertsContent() {
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [rules, setRules] = useState<AlertRule[]>(INIT_RULES)
const [groups, setGroups] = useState<AlertGroup[]>(INIT_GROUPS)
const [history, setHistory] = useState<HistoryEntry[]>(INIT_HISTORY)
const [tg, setTg] = useState<TelegramConfig>(INIT_TG)
const [rules, setRules] = useState<AlertRule[]>(isLive?[]:INIT_RULES)
const [groups, setGroups] = useState<AlertGroup[]>(isLive?[]:INIT_GROUPS)
const [history, setHistory] = useState<HistoryEntry[]>(isLive?[]:INIT_HISTORY)
const [tg, setTg] = useState<TelegramConfig>(isLive?{token:"",chatId:"",messageThreadId:"",connected:false}:INIT_TG)
const [tokenConfigured, setTokenConfigured] = useState(false)
const [meta, setMeta] = useState<AlertsMeta | null>(null)
const [known,setKnown]=useState(!isLive)
const [receivedAt,setReceivedAt]=useState<string|null>(null)
const pageActive=useRef(true),reader=useRef<AbortController|null>(null),revision=useRef(0)
useEffect(()=>{pageActive.current=true;return()=>{pageActive.current=false;reader.current?.abort()}},[])
const [sheetOpen, setSheetOpen] = useState(false)
const [pendingPresetReplaceId, setPendingPresetReplaceId] = useState<string | null>(null)
const [sheetSnap, setSheetSnap] = useState<RuleFormSnapshot | null>(null)
@@ -2407,6 +2415,7 @@ export default function AlertsPage() {
}, [isLive, meta])
const load = useCallback(async () => {
if(!pageActive.current)return
if (!isLive) {
setRules(INIT_RULES)
setGroups(INIT_GROUPS)
@@ -2417,21 +2426,15 @@ export default function AlertsPage() {
setLoadError(null)
return
}
reader.current?.abort()
const controller=new AbortController();reader.current=controller
const requestRevision=++revision.current
setLoadBusy(true)
setLoadError(null)
try {
const data = await apiFetch<{
telegram: {
chatId: string
tokenConfigured: boolean
connected: boolean
messageThreadId: number | null
}
groups?: AlertsApiGroup[]
rules: AlertsApiRule[]
history: AlertsApiHistoryEntry[]
meta: AlertsMeta
}>("/api/alerts")
const data = await getAlerts(backendUrl,{signal:controller.signal})
if(!pageActive.current||requestRevision!==revision.current)return
setKnown(true);setReceivedAt(new Date().toISOString())
setTokenConfigured(data.telegram.tokenConfigured)
setTg({
token: "",
@@ -2445,7 +2448,7 @@ export default function AlertsPage() {
setHistory(mapApiHistory(data.history))
let mergedMeta: AlertsMeta = data.meta
try {
const bgpRows = await apiFetch<BgpSessionListRow[]>("/api/bgp/sessions")
const bgpRows = await apiFetch<BgpSessionListRow[]>("/api/bgp/sessions",{signal:controller.signal})
const bgpPeers = uniqStrings(
bgpRows.map((s) => `${String(s.serverName ?? "").trim()}: ${String(s.name ?? "").trim()}`.trim()),
)
@@ -2457,7 +2460,7 @@ export default function AlertsPage() {
const [srvList, greRes] = await Promise.all([
apiFetch<
Array<{ id: number; name: string; site?: string; country?: string; host?: string }>
>("/api/servers"),
>("/api/servers",{signal:controller.signal}),
apiFetch<{
tunnels: Array<{
id?: string
@@ -2467,7 +2470,7 @@ export default function AlertsPage() {
status?: string
comment?: string
}>
}>("/api/filters/gre-tunnels"),
}>("/api/filters/gre-tunnels",{signal:controller.signal}),
])
const sidToSrv = new Map(
srvList.map((s) => [
@@ -2504,18 +2507,20 @@ export default function AlertsPage() {
} catch {
mergedMeta = { ...mergedMeta, greTunnelTargets: [], greTunnelDetail: [] }
}
setMeta(mergedMeta)
if(pageActive.current&&requestRevision===revision.current)setMeta(mergedMeta)
} catch (e) {
setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
if(pageActive.current&&requestRevision===revision.current)setLoadError(e instanceof Error ? e.message : "Ошибка загрузки")
} finally {
setLoadBusy(false)
if(pageActive.current&&requestRevision===revision.current)setLoadBusy(false)
}
}, [apiFetch, isLive])
}, [apiFetch, isLive, backendUrl])
useEffect(() => {
let current=true
queueMicrotask(() => {
void load()
if(current)void load()
})
return()=>{current=false}
}, [load])
const flushAlertsConfig = useCallback(async (nextRules: AlertRule[], nextGroups: AlertGroup[]) => {
@@ -2637,7 +2642,7 @@ export default function AlertsPage() {
)
const serverNamesForPresets = useMemo(() => {
if (isLive && meta?.servers?.length) return meta.servers
if (isLive) return meta?.servers??[]
return TYPE_TARGETS.server
}, [isLive, meta])
@@ -2811,7 +2816,7 @@ export default function AlertsPage() {
{loadBusy ? <LoaderCircleIcon className="size-4 animate-spin" /> : <RefreshCwIcon className="size-4" />}
Обновить
</Button>
<Button size="sm" onClick={openRuleSheetAdd} disabled={isLive && backendStatus === false}>
<Button size="sm" onClick={openRuleSheetAdd} disabled={!known || (isLive && backendStatus !== true)}>
<PlusIcon className="size-4" />Добавить правило
</Button>
</>
@@ -2839,6 +2844,8 @@ export default function AlertsPage() {
</p>
)}
{isLive?<p role="status" className="text-xs text-muted-foreground">{known?"Последние данные получены: "+new Date(receivedAt!).toLocaleString("ru-RU"):loadBusy?"Загрузка оповещений…":"Данные оповещений ещё не получены."}</p>:null}
{known?<>
<Collapsible open={hintsExpanded} onOpenChange={setHintsExpanded} className="rounded-lg border border-dashed border-border bg-muted/15">
<CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-foreground hover:bg-muted/40 rounded-lg">
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", hintsExpanded && "rotate-180")} />
@@ -3147,6 +3154,7 @@ export default function AlertsPage() {
<HistoryCard entries={history} />
</div>
</div>
</>:null}
</div>
</div>
+3 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 11Б в работе; последний проверенный блок 11Б.4.2.2.1.2; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.1: Alerts UI source lifetime/strict readers/known/error/time; затем drafts/locks/rights/unknown/adaptive, GET consistency/outbox audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Этап 11Б в работе; последний проверенный блок 11Б.4.3.1.1; System DB принят локально с внешними ограничениями. Следующий блок 11Б.4.3.1.2: независимые Alerts BGP/GRE readers/last good metadata/source ABA; затем Telegram/rules drafts/locks/rights/unknown/adaptive и GET/revision/outbox audit. Затем Alerts/Releases/probe scheduling, приёмка 11Б и этап 12.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
@@ -314,3 +314,5 @@ REST запрос → ожидание → подтверждённый output /
2026-10-03: 11Б.4.2.2.1.1 — общая нормализация rules/groups и 400 до удаления; 8 unit и backend profile/builds/typecheck/lint прошли. Далее transaction receipt 11Б.4.2.2.1.2.
2026-10-03: 11Б.4.2.2.1.2 — transaction rules receipt/readback/lock, DI/API/JWT/build/typecheck/lint прошли. Реальный PG rollback/overlap и optimistic revision не приняты. Далее Alerts UI 11Б.4.3.1.
2026-10-03: 11Б.4.3.1.1 — live Alerts без первоначальных demo/0, strict primary reader/known/time, 3 E2E/lint/typecheck/build прошли. Далее independent secondary readers/source 11Б.4.3.1.2.
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.2.2.1.2; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.4.3.1.1; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.4.3.1.1 — первоначальное состояние Alerts и strict reader (2026-10-03)
- Live root больше не инициализируется INIT_RULES/INIT_GROUPS/INIT_HISTORY/INIT_TG. До подтверждённого GET нет demo rows, ложного «Всего: 0» и якобы полученной Telegram конфигурации. Known empty отличается от unknown; основной контент раскрывается только после strict getAlerts. Browser receivedAt показан явно; failed background GET сохраняет последний снимок.
- Source-keyed root, active lifetime/AbortController/revision для GET и secondary requests; late primary/final metadata не публикуются в другой lifetime. Load после unmount не начинает новый reader. Браузерные source/ABA проверки этого раздела ещё впереди; мутации пока старые и не приняты.
- Убран прямой demo fallback для live serverNamesForPresets. Secondary BGP/GRE всё ещё последовательные, часть errors маскируется [] и load busy ждёт их; refresh всё ещё заменяет Telegram drafts. Это промежуточная миграция reader, не приёмка Alerts.
- 3 browser reader сценария (malformed без demo/0/Telegram и известный пустой snapshot с failed refresh), lint, typecheck без incremental и production frontend/contracts build прошли. Первый typecheck обнаружил collision active ref/active count и transient tsbuildinfo write error; ref переименован, nonincremental проверка и build прошли.
- Следующий точный шаг 11Б.4.3.1.2: независимые BGP/GRE readers с known/error/time, сохранение last good metadata и source/ABA E2E. Затем Telegram dirty/selective save/test/unknown, rules confirmation/lock/rights/draft preservation/adaptive. GET consistency, optimistic revision и outbox unknown policy ещё требуют аудита; этап 12 не начат.
## Блок 11Б.4.2.2.1.2 — transactional receipt правил (2026-10-03)
- Replace теперь возвращает strict rules/groups receipt из самой transaction. Omitted groups читает внутри той же transaction после nonblocking pg_try_advisory_xact_lock; lock false даёт 409 без очереди/удаления, unknown lock не подтверждает действие. Targets/conditions/groups/rules/last-fired readback использует tx context вместо отдельных pool запросов после commit.
+2
View File
@@ -244,3 +244,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S
Завершён 11Б.4.2.2.1.1: shared rules/groups normalization до delete, 8 unit/HTTP/JWT/builds/typecheck/lint прошли. Router readback остаётся после commit; omitted groups read вне transaction. Далее 11Б.4.2.2.1.2 transactional receipt/context/DI/concurrency; затем UI/outbox.
Завершён 11Б.4.2.2.1.2: transactional rules/groups receipt/readback и nonblocking replace lock/409; 4 DI + profile/build/typecheck/lint прошли. Real PG и optimistic revision не проверены. Далее 11Б.4.3.1 — Alerts UI source/strict readers/known/error/time; затем drafts/locks/rights/adaptive и GET/outbox audit.
Завершён 11Б.4.3.1.1: primary Alerts strict reader/unknown/known empty/time/last snapshot без live initial mocks; 3 E2E/lint/typecheck/build прошли. Secondary readers/dirty/forms пока прежние. Далее 11Б.4.3.1.2 — независимые BGP/GRE/last good/source ABA; затем forms/rights/adaptive/GET/revision/outbox audit.
+5
View File
@@ -0,0 +1,5 @@
import {test,expect,type Page} from "@playwright/test"
const empty={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
async function setup(page:Page,reply:()=>{status?:number;json:unknown}){await page.route("**/health",r=>r.fulfill({json:{status:"ok"}}));await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(path==="/api/auth/config")return r.fulfill({json:{required:false}});if(path==="/api/alerts")return r.fulfill(reply());if(path==="/api/bgp/sessions"||path==="/api/servers")return r.fulfill({json:[]});if(path==="/api/filters/gre-tunnels")return r.fulfill({json:{tunnels:[]}});return r.fulfill({status:503,json:{error:"outside alerts fixture"}})});await page.goto("/alerts")}
for(const malformed of [{},{...empty,rules:null}])test("Alerts reader: malformed live response does not show demo rules or configured Telegram/"+JSON.stringify(malformed),async({page})=>{await setup(page,()=>({json:malformed}));await expect(page.getByText("Данные оповещений не подтверждены сервером",{exact:true})).toBeVisible();await expect(page.getByText("Данные оповещений ещё не получены.",{exact:true})).toBeVisible();await expect(page.getByRole("button",{name:"Добавить правило",exact:true})).toBeDisabled();await expect(page.getByText("Всего: 0",{exact:true})).toHaveCount(0);await expect(page.getByText("Bot Token",{exact:true})).toHaveCount(0)})
test("Alerts reader: confirmed empty is distinct from unknown and background failure retains snapshot",async({page})=>{let fail=false;await setup(page,()=>fail?{status:503,json:{error:"Refresh unavailable"}}:{json:empty});await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible();fail=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByText("Refresh unavailable",{exact:true})).toBeVisible();await expect(page.getByText("Всего: 0",{exact:true})).toBeVisible();await expect(page.getByText(/Последние данные получены:/)).toBeVisible()})