Прив'язка Telegram: сторінка була, дороги до неї не було
Кнопка під сповіщенням відповідала «ваш Telegram не прив'язано» і не давала виходу. У базі нуль прив'язок і нуль кодів за весь час. Сторінка профілю існує, але пункту меню не мала, а єдиний вхід — ім'я користувача в шапці — малювався за умовою «є ім'я або пошта», тоді як /me віддавало лише пошту. В облікового запису власника, який заводить установник і який входить ІМЕНЕМ, вона порожня. Тобто в типовій інсталяції входу в профіль не було взагалі. * /me віддає username (тип Me на фронтенді його вже вимагав); * вхід у профіль малюється завжди для людини; * пункт меню «Обліковий запис → Мій профіль», perm став необов'язковим; * текст бота називає те, що видно на екрані; * сторінка каналів показує стан прив'язки біля telegram-каналу. Плюс 0073: оренда сходинки ескалації отримала lease_token. Партія переростає 2-хвилинну оренду, і другий інстанс доставляв ту саму сходинку паралельно з першим. Тепер запис проходить лише за збігу токена; при розбіжності не відбувається нічого, сходинка лишається належною. 65 міграцій, усе зелене проти справжньої бази. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ec4b2cd54b
commit
ae07bd2a79
12 changed files with 728 additions and 27 deletions
65
HISTORY.md
65
HISTORY.md
|
|
@ -7612,3 +7612,68 @@ NULL і валить запис. Правило без власних канал
|
|||
міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча
|
||||
стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести
|
||||
(`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-28 — Прив'язка Telegram: сторінка була, дороги до неї не було
|
||||
|
||||
Власник натиснув кнопку під сповіщенням у Telegram і отримав «Ваш Telegram
|
||||
не прив'язано до NetPulse. Профіль → Telegram → отримати код». Відповідь
|
||||
правильна по суті. Виходу з неї не існувало.
|
||||
|
||||
**Що показала база:** нуль прив'язок і нуль згенерованих кодів за весь
|
||||
час. Механізмом не скористався ніхто, включно з власником.
|
||||
|
||||
**Чому.** Сторінка профілю з карткою прив'язки існує й працює. Пункту
|
||||
меню в неї не було; єдиний вхід — власне ім'я користувача в правому
|
||||
верхньому куті. А малювалось воно за умовою «є ім'я або пошта», і `/me`
|
||||
віддавало лише пошту — порожню в облікового запису власника, який
|
||||
заводить установник і який входить ЗА ІМЕНЕМ. Тобто в типовій інсталяції
|
||||
вхід у профіль не малювався взагалі, і сторінка була досяжна лише
|
||||
введенням адреси вручну.
|
||||
|
||||
Тип `Me` у фронтенді при цьому вже містив `username`. Сервер його просто
|
||||
не слав — знову «обіцянка без виконання», тільки на межі шарів.
|
||||
|
||||
**Виправлено чотири речі, кожна закриває свій бік:**
|
||||
|
||||
* `/me` віддає `username`. Одне поле, без якого решта марна.
|
||||
* Вхід у профіль малюється ЗАВЖДИ для людини (не для машинного токена),
|
||||
з іконкою й підписом «Мій профіль» у підказці; без імені й пошти пише
|
||||
просто «Профіль» замість того, щоб зникнути.
|
||||
* Пункт меню «Обліковий запис → Мій профіль». Це свідомий виняток із
|
||||
власного правила файлу («група з одного пункту додала б заголовок,
|
||||
який нічого не групує»): усі інші групи — про систему, ця — про того,
|
||||
хто на неї дивиться, і саме через різницю в роді профіль нікуди не
|
||||
вписався й лишився без пункту. `perm` у пунктів меню став
|
||||
необов'язковим: вигадувати право для власного профілю означало б
|
||||
брехати про модель доступу заради типу.
|
||||
* Текст бота називає те, що людина побачить на екрані, а не назву
|
||||
сторінки в коді.
|
||||
|
||||
Плюс сторінка каналів більше не мовчить: біля telegram-каналу видно, чи
|
||||
прив'язаний Telegram у того, хто дивиться, і що саме не працюватиме без
|
||||
цього. Не червоним — це незавершене налаштування, а не поломка.
|
||||
|
||||
### Оренда сходинки: токен замість строку (0073)
|
||||
|
||||
Остання знахідка рецензії ескалацій. `TakeDueEscalations` ставила в рядок
|
||||
лише СТРОК оренди (2 хв), а партія з сотні сходинок із мережевими
|
||||
таймаутами легко його переростає. Оренда спливала, другий інстанс брав ту
|
||||
саму сходинку з тим самим `step_idx` і доставляв її — а перший доставляв
|
||||
теж. Два дзвінки о третій ночі й два рядки «надіслано».
|
||||
|
||||
Тепер у рядку лежить `lease_token`, і `ApplyEscalation` пише лише за його
|
||||
збігу. Довша оренда цього не вирішила б: довший строк — це пересунута
|
||||
межа, і він псує протилежний випадок (процес, що впав одразу після
|
||||
взяття). При розбіжності не відбувається НІЧОГО: запис не проходить,
|
||||
доставки немає (стан пишеться перед нею), оренду того, хто зараз працює,
|
||||
не зривають. Сходинка лишається належною, і її доводить до розв'язку
|
||||
власник токена — або, якщо і він зник, наступний такт після спливання
|
||||
строку.
|
||||
|
||||
Свідомо НЕ вимагається, щоб оренда була ще й живою: інакше кожна повільна
|
||||
партія втрачала б усі свої сходинки, і драбина повзла б зі швидкістю
|
||||
однієї сходинки за такт.
|
||||
|
||||
Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ const escalationBatch = 100
|
|||
// чергу через FOR UPDATE SKIP LOCKED, а оренда на рядку
|
||||
// (EscalationLease) — другий рубіж на випадок, коли рядок усе-таки взяли
|
||||
// двоє.
|
||||
//
|
||||
// Партія при цьому МОЖЕ пережити власну оренду, і це не аварія, а
|
||||
// нормальний вечір: escalationBatch сходинок, кожна з мережевим
|
||||
// таймаутом на канал, легко переростають дві хвилини. Рятує від
|
||||
// подвоєння не строк, а токен оренди (0073): ApplyEscalation відмовить
|
||||
// сходинці, чию оренду вже перебрали, і надсилання до неї не дійде — бо
|
||||
// стан пишеться ПЕРЕД доставкою, а не після.
|
||||
func (e *Engine) escalate(ctx context.Context) {
|
||||
due, err := e.st.TakeDueEscalations(ctx, escalationBatch)
|
||||
if err != nil {
|
||||
|
|
@ -88,10 +95,16 @@ func (e *Engine) escalate(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
if !applied {
|
||||
// Драбину зупинили, поки сходинка чекала своєї черги —
|
||||
// найчастіше людина натиснула «Прийняти». Це не помилка, це
|
||||
// той випадок, заради якого кнопка й існує.
|
||||
e.log.Info("сходинку скасовано: драбину вже зупинено",
|
||||
// Три причини, і всі — не помилка.
|
||||
//
|
||||
// Драбину зупинили, поки сходинка чекала своєї черги
|
||||
// (найчастіше людина натиснула «Прийняти») — це той випадок,
|
||||
// заради якого кнопка й існує. Алерт погас іншим шляхом —
|
||||
// дзвонити нема про що. Або оренду перебрали: партія
|
||||
// затягнулась довше за EscalationLease, і сходинку вже веде
|
||||
// інше взяття (токен у 0073). У всіх трьох сходинка НЕ
|
||||
// доставляється — саме тому запис іде перед надсиланням.
|
||||
e.log.Info("сходинку скасовано: драбину зупинено, алерт погас або оренду перебрано",
|
||||
"алерт", snap.AlertID, "сходинка", d.StepIdx+1)
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -354,8 +354,16 @@ func (b *Bot) handleCallback(ctx context.Context, g botGroup, cq *tgCallbackQuer
|
|||
|
||||
acc, err := b.st.TelegramAccountByTgID(ctx, ch.TenantID, cq.From.ID)
|
||||
if errors.Is(err, store.ErrTelegramNotLinked) {
|
||||
// Текст називає те, що людина побачить на екрані, а не назву
|
||||
// сторінки в коді. Попередній варіант відсилав до «Профіль →
|
||||
// Telegram», і саме так це вперше й не спрацювало: пункту
|
||||
// «Профіль» у меню не було, посилання в шапці не малювалось
|
||||
// (власник входить іменем, а не поштою, а /me імені не
|
||||
// віддавало), і людина, яка сумлінно виконала інструкцію,
|
||||
// доходила до висновку, що зламалось.
|
||||
answer = "Ваш Telegram не прив'язано до NetPulse.\n" +
|
||||
"Профіль → Telegram → отримати код, потім надішліть боту /link КОД"
|
||||
"У NetPulse: меню → Мій профіль → картка «Telegram» → " +
|
||||
"«Отримати код», далі надішліть цьому боту: /link КОД"
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -240,6 +240,17 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request, p *Principal)
|
|||
if p.IsUser() {
|
||||
resp["user_id"] = p.UserID
|
||||
resp["email"] = p.Email
|
||||
// Ім'я входу віддається обов'язково, і це не дрібниця.
|
||||
//
|
||||
// Обліковий запис власника, який заводить установник, входить за
|
||||
// ІМЕНЕМ (admin), а не за поштою — тобто email у нього порожній.
|
||||
// Інтерфейс показував посилання на профіль лише за наявності
|
||||
// імені або пошти, тож у типовій інсталяції воно не малювалось
|
||||
// узагалі, і сторінка профілю ставала недосяжною: ні з меню, ні
|
||||
// з шапки. Разом із нею — прив'язка Telegram, без якої кнопки
|
||||
// під сповіщеннями відповідають «ваш Telegram не прив'язано» і
|
||||
// не дають виходу.
|
||||
resp["username"] = p.Username
|
||||
} else {
|
||||
resp["token_id"] = p.TokenID
|
||||
resp["token_name"] = p.TokenName
|
||||
|
|
|
|||
|
|
@ -375,6 +375,15 @@ type EscalationSnapshot struct {
|
|||
PassStart time.Time
|
||||
Deadline time.Time
|
||||
|
||||
// Токен оренди, під якою цю сходинку взято (0073).
|
||||
//
|
||||
// Не інформація, а перепустка: ApplyEscalation запише рішення лише
|
||||
// тоді, коли в рядку лежить рівно цей токен. Знімок, чия оренда
|
||||
// встигла спливти й дістатись іншому взяттю, стає недійсним — і
|
||||
// саме тому обробка партії, довша за EscalationLease, більше не
|
||||
// коштує другого дзвінка о третій ночі.
|
||||
LeaseToken string
|
||||
|
||||
Steps []EscalationStep
|
||||
RepeatAfterMin int
|
||||
MaxRepeats int
|
||||
|
|
@ -620,6 +629,14 @@ func PlanEscalation(s EscalationSnapshot, now time.Time) EscalationDecision {
|
|||
// сходинки — це той самий свідомий вибір, що вже зроблено для черги
|
||||
// подієвих алертів у 0058: «спробували» не дорівнює «доставили», і
|
||||
// краще не надіслати, ніж надіслати вдруге о третій ночі.
|
||||
//
|
||||
// Строк лишається коротким навмисно, хоч партія буває довшою за нього.
|
||||
// Подовжити його «щоб вистачало» неможливо: сотня сходинок, кожна з
|
||||
// власним мережевим таймаутом, переросте будь-яке число, а довга
|
||||
// оренда робить гірше тому єдиному випадку, заради якого вона й
|
||||
// існує, — процесу, що впав одразу після взяття. Розрив «партія довша
|
||||
// за оренду» закриває не строк, а токен оренди (0073): володіння
|
||||
// звіряється при записі, тож перебрана сходинка вже не подвоюється.
|
||||
const EscalationLease = 2 * time.Minute
|
||||
|
||||
// TakeDueEscalations забирає сходинки, час яких настав.
|
||||
|
|
@ -627,6 +644,13 @@ const EscalationLease = 2 * time.Minute
|
|||
// Наскрізно по всіх кабінетах і робочим пулом — так само, як черга
|
||||
// подієвих алертів: движок один на інсталяцію й крутиться під
|
||||
// advisory-блокуванням.
|
||||
//
|
||||
// Разом зі строком оренди рядок отримує ТОКЕН цього взяття
|
||||
// (core.new_id() у SET обчислюється для кожного рядка окремо). Строк
|
||||
// каже лише «зайнято до», і після його спливання рядок дістається
|
||||
// іншому взяттю — а перше про це не дізнається й піде доставляти. Токен
|
||||
// перетворює строк на володіння: ApplyEscalation запише рішення лише
|
||||
// під тим токеном, що лежить у рядку зараз.
|
||||
func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]EscalationSnapshot, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
|
|
@ -642,13 +666,16 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
|||
FOR UPDATE SKIP LOCKED
|
||||
), taken AS (
|
||||
UPDATE alr.alert_escalations e
|
||||
SET leased_until = now() + $2::interval
|
||||
SET leased_until = now() + $2::interval,
|
||||
lease_token = core.new_id()
|
||||
FROM due d WHERE e.alert_id = d.alert_id
|
||||
RETURNING e.alert_id, e.tenant_id, e.policy_id, e.is_event,
|
||||
e.step_idx, e.repeat_idx, e.pass_start, e.deadline
|
||||
e.step_idx, e.repeat_idx, e.pass_start, e.deadline,
|
||||
e.lease_token
|
||||
)
|
||||
SELECT t.alert_id::text, t.tenant_id::text, COALESCE(t.policy_id::text,''),
|
||||
t.is_event, t.step_idx, t.repeat_idx, t.pass_start, t.deadline,
|
||||
t.lease_token::text,
|
||||
COALESCE(p.name,''), COALESCE(p.steps::text,'[]'),
|
||||
COALESCE(p.repeat_after_min,0), COALESCE(p.max_repeats,0),
|
||||
COALESCE(p.respect_quiet_hours,false),
|
||||
|
|
@ -675,7 +702,7 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
|||
var s EscalationSnapshot
|
||||
var steps, sched string
|
||||
if err := rows.Scan(&s.AlertID, &s.TenantID, &s.PolicyID, &s.IsEvent,
|
||||
&s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline,
|
||||
&s.StepIdx, &s.RepeatIdx, &s.PassStart, &s.Deadline, &s.LeaseToken,
|
||||
&s.PolicyName, &steps, &s.RepeatAfterMin, &s.MaxRepeats,
|
||||
&s.RespectQuietHours, &sched,
|
||||
&s.AlertState, &s.Alert.Severity, &s.Alert.Title, &s.Alert.Message,
|
||||
|
|
@ -728,6 +755,16 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
|||
// його й воскресив зупинену драбину — розбудив би саме того, хто
|
||||
// щойно сказав «я цим займаюсь».
|
||||
//
|
||||
// оренда все ще НАША (lease_token, 0073) — третя умова, і вона про
|
||||
// інше: не про алерт, а про право писати. Партія буває довшою за
|
||||
// EscalationLease, і тоді рядок дістається наступному взяттю ще до
|
||||
// того, як руки дійшли до нашої сходинки. Без звірки токена обидва
|
||||
// доставили б ту саму сходинку, і в журналі стояло б два «надіслано»
|
||||
// на одну — тобто саме той подвійний дзвінок о третій ночі, від якого
|
||||
// весь цей порядок і побудовано. Токен звіряється завжди, а не лише
|
||||
// для EscFire: запис зупинки чи відкладання від чужого імені так само
|
||||
// зсунув би next_at під ногами того, хто зараз працює.
|
||||
//
|
||||
// алерт усе ще 'firing' (лише для сходинки, що має спрацювати) —
|
||||
// решта шляхів гасіння рядка драбини НЕ чіпають: вимкнення чи
|
||||
// видалення правила (resolveRuleAlerts), відновлення метрики
|
||||
|
|
@ -740,7 +777,10 @@ func (s *Store) TakeDueEscalations(ctx context.Context, limit int) ([]Escalation
|
|||
//
|
||||
// Якщо жодного рядка не оновлено, наступний такт візьме цю сходинку
|
||||
// знову, PlanEscalation побачить 'resolved' і зупинить драбину штатно —
|
||||
// тобто вона не застрягне.
|
||||
// тобто вона не застрягне. Те саме стосується й розбіжного токена:
|
||||
// сходинка лишається належною (next_at не зрушено), її доводить до
|
||||
// розв'язку той, чий токен зараз у рядку, а якщо не дійде й він —
|
||||
// оренда спливе за EscalationLease, і сходинку переберуть заново.
|
||||
func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d EscalationDecision) (bool, error) {
|
||||
var next any
|
||||
if d.NextAt != nil {
|
||||
|
|
@ -757,17 +797,23 @@ func (s *Store) ApplyEscalation(ctx context.Context, snap EscalationSnapshot, d
|
|||
pass_start = $4,
|
||||
next_at = $5,
|
||||
leased_until = NULL,
|
||||
lease_token = NULL,
|
||||
last_step_at = CASE WHEN $6 THEN now() ELSE last_step_at END,
|
||||
stopped_at = CASE WHEN $5::timestamptz IS NULL THEN now() ELSE NULL END,
|
||||
stop_reason = CASE WHEN $5::timestamptz IS NULL THEN $7 ELSE NULL END
|
||||
WHERE alert_id = $1
|
||||
AND stopped_at IS NULL
|
||||
-- Знімок без токена (він міг лишитись від процесу, що
|
||||
-- пережив накат 0073) до запису не допускається: NULL = NULL
|
||||
-- хибне, тож рядків не буде. Це правильний бік помилки —
|
||||
-- сходинку переберуть, а не подвоять.
|
||||
AND lease_token = $8::uuid
|
||||
AND (NOT $6 OR EXISTS (
|
||||
SELECT 1 FROM alr.alerts a
|
||||
WHERE a.id = alr.alert_escalations.alert_id
|
||||
AND a.state = 'firing'))
|
||||
`, snap.AlertID, d.NextStepIdx, d.NextRepeatIdx, d.NextPassStart,
|
||||
next, fired, d.Outcome)
|
||||
next, fired, d.Outcome, nullUUID(snap.LeaseToken))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -862,7 +908,7 @@ func stopEscalationTx(ctx context.Context, tx pgx.Tx, tenantID, alertID, reason,
|
|||
_, err := tx.Exec(ctx, `
|
||||
WITH stopped AS (
|
||||
UPDATE alr.alert_escalations
|
||||
SET next_at = NULL, leased_until = NULL,
|
||||
SET next_at = NULL, leased_until = NULL, lease_token = NULL,
|
||||
stopped_at = now(), stop_reason = $3
|
||||
WHERE tenant_id = $1 AND alert_id = $2 AND stopped_at IS NULL
|
||||
RETURNING policy_id, step_idx, repeat_idx
|
||||
|
|
|
|||
|
|
@ -351,6 +351,190 @@ func TestEscalationAgainstDB(t *testing.T) {
|
|||
t.Error("сходинка не повернулась у чергу — драбину нікому зупинити")
|
||||
}
|
||||
|
||||
// --- Оренда: токен, а не строк ------------------------------------
|
||||
//
|
||||
// Розрив, якого не закривала сама лише leased_until. Партія — до
|
||||
// сотні сходинок, кожна доставка з власним мережевим таймаутом, а
|
||||
// оренда — дві хвилини: партія переростає її на першому ж підвислому
|
||||
// каналі. Тоді рядок дістається наступному взяттю ще ДО того, як
|
||||
// руки дійшли до нашої сходинки, і без звірки володіння обидва
|
||||
// доставили б ту саму сходинку — два дзвінки о третій ночі й два
|
||||
// рядки «надіслано» на одну сходинку.
|
||||
//
|
||||
// Нижче відтворено рівно цей порядок і перевірено три речі, кожна з
|
||||
// яких могла б піти не так окремо.
|
||||
|
||||
// (1) Оренда спливла, але НІХТО не перебрав — запис має пройти.
|
||||
//
|
||||
// Це не дрібниця й не піддавки: якби токен вимагав ще й живого
|
||||
// строку, кожна повільна партія втрачала б усі свої сходинки, і
|
||||
// драбина повзла б зі швидкістю одного такту на сходинку. Токен
|
||||
// доводить володіння, а володіння не зникає від того, що годинник
|
||||
// пробив, поки на рядок ніхто інший не претендує.
|
||||
a5 := newAlert(ruleID + ":dev:" + deviceID + ":5")
|
||||
if err := st.ArmEscalation(ctx, tenantID, a5, policyID, false, policy, started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
|
||||
a5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// takeOne бере чергу й дістає з неї знімок потрібного алерту.
|
||||
takeOne := func(alertID string) EscalationSnapshot {
|
||||
t.Helper()
|
||||
batch, err := st.TakeDueEscalations(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("взяття черги: %v", err)
|
||||
}
|
||||
for _, x := range batch {
|
||||
if x.AlertID == alertID {
|
||||
return x
|
||||
}
|
||||
}
|
||||
t.Fatalf("сходинка алерту %s не потрапила в чергу", alertID)
|
||||
return EscalationSnapshot{}
|
||||
}
|
||||
|
||||
// expireLease старить оренду, НЕ чіпаючи токена: саме так виглядає
|
||||
// рядок, коли процес ще йде по партії, а строк уже вийшов.
|
||||
expireLease := func(alertID string) {
|
||||
t.Helper()
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`UPDATE alr.alert_escalations SET leased_until = now() - interval '1 second'
|
||||
WHERE alert_id = $1`, alertID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ladder читає стан драбини так, як його бачить база.
|
||||
ladder := func(alertID string) (stepIdx int, token string, nextAt *time.Time) {
|
||||
t.Helper()
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT step_idx, COALESCE(lease_token::text,''), next_at
|
||||
FROM alr.alert_escalations WHERE alert_id = $1
|
||||
`, alertID).Scan(&stepIdx, &token, &nextAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return stepIdx, token, nextAt
|
||||
}
|
||||
|
||||
sLate := takeOne(a5)
|
||||
if sLate.LeaseToken == "" {
|
||||
t.Fatal("взяття не поставило токен оренди — звіряти буде нічого")
|
||||
}
|
||||
expireLease(a5)
|
||||
dLate := PlanEscalation(sLate, time.Now())
|
||||
if dLate.Action != EscFire {
|
||||
t.Fatalf("сходинка мала спрацювати: %v/%s", dLate.Action, dLate.Outcome)
|
||||
}
|
||||
okLate, err := st.ApplyEscalation(ctx, sLate, dLate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okLate {
|
||||
t.Error("запис не пройшов, хоча оренду ніхто не перебирав — " +
|
||||
"повільна партія втрачатиме всі свої сходинки")
|
||||
}
|
||||
|
||||
// (2) Оренду ПЕРЕБРАЛИ — запізнілий запис не має пройти.
|
||||
a6 := newAlert(ruleID + ":dev:" + deviceID + ":6")
|
||||
if err := st.ArmEscalation(ctx, tenantID, a6, policyID, false, policy, started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`UPDATE alr.alert_escalations SET next_at = now() - interval '1 minute' WHERE alert_id = $1`,
|
||||
a6); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := takeOne(a6) // перший інстанс: узяв і пішов доставляти партію
|
||||
expireLease(a6) // партія затягнулась довше за оренду
|
||||
second := takeOne(a6) // другий інстанс перебрав ту саму сходинку
|
||||
|
||||
if first.LeaseToken == second.LeaseToken {
|
||||
t.Fatal("два взяття отримали однаковий токен — перевірка нічого не доводить")
|
||||
}
|
||||
if first.StepIdx != second.StepIdx {
|
||||
t.Fatalf("другому взяттю дісталась інша сходинка (%d проти %d) — "+
|
||||
"відтворено не той розрив", second.StepIdx, first.StepIdx)
|
||||
}
|
||||
|
||||
dFirst := PlanEscalation(first, time.Now())
|
||||
if dFirst.Action != EscFire {
|
||||
t.Fatalf("сходинка мала спрацювати: %v/%s", dFirst.Action, dFirst.Outcome)
|
||||
}
|
||||
okFirst, err := st.ApplyEscalation(ctx, first, dFirst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if okFirst {
|
||||
t.Error("запис пройшов під перебраною орендою — сходинка піде в канали вдруге")
|
||||
}
|
||||
|
||||
// Відмова має бути БЕЗ наслідків: чужий запис не зриває ані оренду
|
||||
// того, хто зараз працює, ані стан драбини. Інакше запізнілий
|
||||
// інстанс, нічого не доставивши, псував би роботу тому, хто доставить.
|
||||
stepAfter, tokenAfter, nextAfter := ladder(a6)
|
||||
if stepAfter != first.StepIdx {
|
||||
t.Errorf("невдалий запис усе-таки просунув драбину: step_idx = %d", stepAfter)
|
||||
}
|
||||
if tokenAfter != second.LeaseToken {
|
||||
t.Errorf("невдалий запис зірвав оренду того, хто працює: токен %q", tokenAfter)
|
||||
}
|
||||
if nextAfter == nil {
|
||||
t.Error("невдалий запис зняв час наступної сходинки — драбина осиротіла")
|
||||
}
|
||||
|
||||
// (3) І драбина не застрягла.
|
||||
//
|
||||
// Найгірший розклад: другий інстанс, який перебрав сходинку, теж не
|
||||
// дійшов до запису (впав, викотили нову версію). Тоді сходинка не
|
||||
// доставлена НІКИМ, і єдине, що має її врятувати, — спливання
|
||||
// оренди. Перевіряємо, що наступний такт бере її з тим самим
|
||||
// step_idx і доводить до розв'язку.
|
||||
expireLease(a6)
|
||||
third := takeOne(a6)
|
||||
if third.LeaseToken == second.LeaseToken || third.LeaseToken == first.LeaseToken {
|
||||
t.Error("нове взяття не оновило токен — оренда перестала бути власністю")
|
||||
}
|
||||
if third.StepIdx != first.StepIdx {
|
||||
t.Errorf("сходинку загублено: наступний такт узяв step_idx = %d замість %d",
|
||||
third.StepIdx, first.StepIdx)
|
||||
}
|
||||
dThird := PlanEscalation(third, time.Now())
|
||||
if dThird.Action != EscFire {
|
||||
t.Fatalf("сходинка мала спрацювати: %v/%s", dThird.Action, dThird.Outcome)
|
||||
}
|
||||
okThird, err := st.ApplyEscalation(ctx, third, dThird)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okThird {
|
||||
t.Fatal("наступний такт не зміг записати рішення — драбина застрягла назавжди")
|
||||
}
|
||||
stepDone, tokenDone, nextDone := ladder(a6)
|
||||
if stepDone != first.StepIdx+1 {
|
||||
t.Errorf("драбина не просунулась: step_idx = %d", stepDone)
|
||||
}
|
||||
if tokenDone != "" {
|
||||
t.Errorf("токен не знято разом з орендою: %q", tokenDone)
|
||||
}
|
||||
if nextDone == nil {
|
||||
t.Error("у живої драбини не лишилось часу наступної сходинки")
|
||||
}
|
||||
|
||||
// І найпізніший з усіх: перший інстанс, який усе ще тримає свій
|
||||
// протухлий знімок, після цього теж нічого не запише.
|
||||
okStale, err := st.ApplyEscalation(ctx, first, dFirst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if okStale {
|
||||
t.Error("протухлий знімок записався після просування драбини — сходинка подвоїлась")
|
||||
}
|
||||
|
||||
// --- Закритий алерт: сходинка не спрацьовує навіть якщо настала ----
|
||||
a2 := newAlert(ruleID + ":dev:" + deviceID + ":2")
|
||||
if err := st.ArmEscalation(ctx, tenantID, a2, policyID, false, policy, started); err != nil {
|
||||
|
|
|
|||
93
server/migrations/0073_escalation_lease_token.sql
Normal file
93
server/migrations/0073_escalation_lease_token.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0073_escalation_lease_token.sql
|
||||
-- Оренда сходинки ескалації отримує токен: хто взяв, той і записує.
|
||||
--
|
||||
-- ЩО БУЛО НЕ ТАК
|
||||
--
|
||||
-- 0066 дала оренді рядка (leased_until) рівно половину роботи. Вона
|
||||
-- чесно закриває вікно «взяли — ще не записали»: доки оренда не
|
||||
-- спливла, другий інстанс сходинку не побачить. Але оренда — це строк,
|
||||
-- а не володіння, і про друге вона не знає нічого.
|
||||
--
|
||||
-- Строк тут дві хвилини (store.EscalationLease), а партія — до сотні
|
||||
-- сходинок (alerting.escalationBatch), і кожна доставка в ній має
|
||||
-- ВЛАСНИЙ мережевий таймаут: Telegram, SMTP, вебхук у чужий контур.
|
||||
-- Тобто «партія довша за оренду» — це не рідкісний збіг, а звичайний
|
||||
-- вечір, коли один канал підвис.
|
||||
--
|
||||
-- Далі рахунок простий. Оренда спливає, поки перший інстанс ще йде по
|
||||
-- партії. Другий (або наступний такт того самого процесу) бачить рядок
|
||||
-- вільним, бере ту саму сходинку — з тим самим step_idx, бо перший її
|
||||
-- ще не просунув, — і доставляє. Через хвилину доходить черга до неї в
|
||||
-- першого, і він доставляє її вдруге. О третій ночі це два дзвінки, а в
|
||||
-- журналі — два рядки «надіслано», тобто доказ перетворюється на
|
||||
-- виправдання: за ним не видно, що система подзвонила двічі за одну
|
||||
-- сходинку.
|
||||
--
|
||||
-- ApplyEscalation при цьому мав ДВІ перевірки стану (stopped_at IS NULL
|
||||
-- і «алерт усе ще firing»), і обидві потрібні — але обидві про АЛЕРТ.
|
||||
-- Жодна не про те, чи оренда все ще наша. Звірити було нічим: у рядку
|
||||
-- лежав лише строк, і строк першого інстансу нічим не відрізнявся від
|
||||
-- строку другого.
|
||||
--
|
||||
-- ЧОМУ ТОКЕН, А НЕ ДОВША ОРЕНДА
|
||||
--
|
||||
-- Спокуса очевидна: зробити оренду двадцять хвилин, і партія завжди
|
||||
-- вкладеться. Це не рішення, а пересунута межа. Скільки б не було
|
||||
-- поставлено, знайдеться партія довша: сотня сходинок по три канали,
|
||||
-- кожен зі своїм таймаутом, — це арифметика, а не патологія. Крім
|
||||
-- того, довга оренда псує протилежний випадок: процес, що впав одразу
|
||||
-- після взяття, тримає сходинку заручницею рівно на цей строк, а
|
||||
-- сходинка ескалації — це той, кого не розбудили.
|
||||
--
|
||||
-- Токен знімає обидва: строк лишається коротким (впав — сходинку
|
||||
-- швидко перебирають), а подвоєння закриває звірка володіння, яка від
|
||||
-- строку не залежить взагалі. Це той самий fencing-токен, що й у
|
||||
-- розподілених блокуваннях: власність доводиться не тим, що годинник ще
|
||||
-- не пробив, а тим, що номер збігся.
|
||||
--
|
||||
-- ЧОМУ ТОКЕН НА РЯДОК, А НЕ НА ПАРТІЮ
|
||||
--
|
||||
-- Один токен на всю партію теж закрив би подвоєння, і коду було б на
|
||||
-- рядок менше. Але тоді два різні прогони, що поділили одну партію
|
||||
-- (SKIP LOCKED саме для цього й стоїть), розрізнялись би лише
|
||||
-- випадковістю збігу, а рядок у базі не казав би, ЯКЕ взяття його
|
||||
-- тримає. core.new_id() у SET обчислюється для кожного рядка окремо —
|
||||
-- ціна нульова, а стовпець стає самодостатнім: за ним видно конкретне
|
||||
-- взяття конкретної сходинки.
|
||||
--
|
||||
-- ЩО СТАНЕТЬСЯ, КОЛИ ТОКЕН НЕ ЗБІГСЯ
|
||||
--
|
||||
-- Нічого не станеться — і це головна вимога до цього стовпця. UPDATE
|
||||
-- не чіпає ЖОДНОГО рядка: ні next_at, ні step_idx, ні leased_until
|
||||
-- (інакше запізнілий інстанс зривав би оренду того, хто зараз працює).
|
||||
-- Движок бачить applied = false, у мережу нічого не йде, і сходинка
|
||||
-- лишається належною — тим самим step_idx, з тим самим next_at у
|
||||
-- минулому. Її доводить до розв'язку той, чий токен у рядку; якщо й він
|
||||
-- не дійшов — оренда спливає за дві хвилини, і наступний такт бере
|
||||
-- сходинку заново. Застрягти назавжди нема від чого: жодна гілка не
|
||||
-- знімає next_at і не подовжує оренду безкінечно.
|
||||
--
|
||||
-- СУМІСНІСТЬ
|
||||
--
|
||||
-- Стовпець NULL для всіх наявних рядків, і це правильний стан: NULL
|
||||
-- означає «зараз ніхто не тримає». Живі оренди на момент накату
|
||||
-- (щонайбільше дві хвилини) отримають токен при наступному взятті.
|
||||
-- Вимоги NOT NULL немає навмисно — вільний рядок мусить уміти сказати
|
||||
-- «нічий», а не носити токен-привид від позаминулого взяття.
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE alr.alert_escalations
|
||||
ADD COLUMN IF NOT EXISTS lease_token uuid;
|
||||
|
||||
COMMENT ON COLUMN alr.alert_escalations.lease_token IS
|
||||
'Токен поточної оренди (fencing). Ставиться разом із leased_until при '
|
||||
'взятті сходинки, звіряється при записі рішення й знімається разом з '
|
||||
'орендою. NULL — сходинку зараз ніхто не тримає. Потрібен тому, що '
|
||||
'обробка партії буває довшою за оренду: без звірки володіння запізнілий '
|
||||
'інстанс доставив би сходинку, яку вже доставив той, хто перебрав її '
|
||||
'після спливання оренди.';
|
||||
|
||||
COMMENT ON COLUMN alr.alert_escalations.leased_until IS
|
||||
'До якого моменту сходинка вважається взятою в роботу. Строк, а не '
|
||||
'володіння: володіння доводить lease_token.';
|
||||
|
|
@ -12,7 +12,14 @@ interface NavItem {
|
|||
to: string
|
||||
label: string
|
||||
icon: string
|
||||
perm: Permission
|
||||
/**
|
||||
* Право, без якого пункт не показується.
|
||||
*
|
||||
* Необов'язкове: є сторінки, які належать кожному, хто увійшов
|
||||
* (власний профіль), і вигадувати для них право означало б брехати
|
||||
* про модель доступу заради типу.
|
||||
*/
|
||||
perm?: Permission
|
||||
/** Показувати лічильник алертів на цьому пункті. */
|
||||
badge?: boolean
|
||||
}
|
||||
|
|
@ -190,6 +197,27 @@ const navGroups: NavGroup[] = [
|
|||
{ to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Окрема група з одного пункту — і це свідомий виняток із правила,
|
||||
// записаного вище («група з одного пункту додала б заголовок,
|
||||
// який нічого не групує»).
|
||||
//
|
||||
// Причина винятку: усі інші групи — про СИСТЕМУ, а ця про того,
|
||||
// хто на неї дивиться. Заголовок тут групує саме це — і саме через
|
||||
// цю різницю в роді профіль нікуди не вписався й лишився без пункту
|
||||
// взагалі — рівно той випадок, про який написано вище:
|
||||
// «сторінка, про існування якої знає лише той, хто її писав».
|
||||
//
|
||||
// Ціна була не косметична: тут живе прив'язка Telegram, без якої
|
||||
// кнопки під сповіщеннями відповідають «ваш Telegram не прив'язано».
|
||||
// За весь час цим не скористався ніхто — ні однієї прив'язки й жодного
|
||||
// згенерованого коду, включно з власником.
|
||||
//
|
||||
// Без perm: профіль є в кожного, хто увійшов.
|
||||
id: 'account',
|
||||
title: 'Обліковий запис',
|
||||
items: [{ to: '/profile', label: 'Мій профіль', icon: '👤' }],
|
||||
},
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
|
@ -392,7 +420,8 @@ export function AppShell() {
|
|||
// Група без жодного доступного пункту зникає цілком: заголовок над
|
||||
// порожнечею обіцяє розділ, якого для цієї ролі не існує.
|
||||
const groups = navGroups
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => session.can(i.perm)) }))
|
||||
// Пункт без perm бачать усі, хто увійшов.
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => !i.perm || session.can(i.perm)) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
const hasAny = groups.some((g) => g.items.length > 0)
|
||||
|
||||
|
|
@ -454,15 +483,26 @@ export function AppShell() {
|
|||
</NavLink>
|
||||
)}
|
||||
|
||||
{(me?.username || me?.email) && (
|
||||
{/*
|
||||
Вхід у профіль малюється ЗАВЖДИ, а не лише коли є чим
|
||||
підписати кнопку.
|
||||
|
||||
Раніше умова вимагала імені або пошти, а /me віддавало лише
|
||||
пошту — порожню в облікового запису власника, який заводить
|
||||
установник і який входить за іменем. Тобто в типовій
|
||||
інсталяції єдиний шлях у профіль не малювався взагалі, і
|
||||
сторінка була досяжна лише введенням адреси вручну.
|
||||
*/}
|
||||
{me?.userID && (
|
||||
<div className="flex items-center gap-1">
|
||||
<NavLink
|
||||
to="/profile"
|
||||
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
|
||||
text-slate-400 hover:bg-slate-800"
|
||||
title={me.email || me.username}
|
||||
className="flex max-w-[10rem] items-center gap-1 truncate rounded border
|
||||
border-slate-700 px-2 py-1 text-slate-300 hover:bg-slate-800"
|
||||
title="Мій профіль"
|
||||
>
|
||||
{me.username || me.email}
|
||||
<span aria-hidden>👤</span>
|
||||
<span className="truncate">{me?.username || me?.email || 'Профіль'}</span>
|
||||
</NavLink>
|
||||
<button
|
||||
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import {
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { Channel, Severity } from '../types'
|
||||
import type { Channel, Severity, TelegramLink } from '../types'
|
||||
|
||||
const kindLabel: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
|
|
@ -34,6 +35,16 @@ const severityLabel: Record<Severity, string> = {
|
|||
disaster: 'Лише аварії',
|
||||
}
|
||||
|
||||
/**
|
||||
* Як назвати прив'язаний акаунт. Той самий порядок, що й у картці
|
||||
* профілю: @username, інакше ім'я, інакше числовий id — щоб та сама
|
||||
* прив'язка не називалась на двох сторінках по-різному.
|
||||
*/
|
||||
function tgName(l: TelegramLink): string {
|
||||
if (l.tg_username) return `@${l.tg_username}`
|
||||
return l.tg_name || String(l.tg_user_id)
|
||||
}
|
||||
|
||||
export function ChannelsPage() {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -46,8 +57,39 @@ export function ChannelsPage() {
|
|||
null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Прив'язка Telegram того, хто зараз дивиться.
|
||||
*
|
||||
* `undefined` — ще не з'ясовано: або вантажиться, або запит не вдався.
|
||||
* Три стани, а не два, саме тому, що підказка тут другорядна: збій
|
||||
* запиту про прив'язку не має перетворюватись на червону помилку над
|
||||
* переліком каналів — вона стосується зовсім іншого.
|
||||
*/
|
||||
const [tgLink, setTgLink] = useState<TelegramLink | null | undefined>(undefined)
|
||||
|
||||
const canWrite = session.can('alerts:write')
|
||||
|
||||
// Прив'язка цікавить лише там, де є кому нею скористатись. Без
|
||||
// telegram-каналів це був би запит на кожне відкриття сторінки заради
|
||||
// підказки, якої однаково не буде.
|
||||
const hasTelegram = channels.some((c) => c.kind === 'telegram')
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTelegram) return
|
||||
let alive = true
|
||||
api.getTelegramLink().then(
|
||||
(l) => {
|
||||
if (alive) setTgLink(l)
|
||||
},
|
||||
() => {
|
||||
if (alive) setTgLink(undefined)
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [hasTelegram])
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setChannels(await api.listChannels())
|
||||
|
|
@ -92,6 +134,40 @@ export function ChannelsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Канал і прив'язка — різні речі, і сторінка каналів мовчала про
|
||||
другу. Ззовні налаштування виглядало завершеним: канал є,
|
||||
«Перевірити» проходить, повідомлення приходять. А кнопки під
|
||||
ними особисті, і без прив'язки бот відповідає відмовою — з
|
||||
якої виходу тут не було написано.
|
||||
|
||||
Обидва стани промовляються вголос: мовчання читається як «не
|
||||
налаштовано», тобто не відрізняється від справжньої проблеми.
|
||||
*/}
|
||||
{hasTelegram && tgLink !== undefined && (
|
||||
<div className="p-4 pb-0">
|
||||
{tgLink ? (
|
||||
<div className="rounded border border-emerald-900/60 bg-emerald-950/40 px-3 py-2 text-xs text-emerald-200">
|
||||
Ваш Telegram прив’язано ({tgName(tgLink)}) — кнопки «Прийняти» й «Заглушити»
|
||||
під сповіщеннями спрацьовують від вашого імені. Кожен, хто має ними
|
||||
користуватись, прив’язує свій окремо.
|
||||
</div>
|
||||
) : (
|
||||
// Не помилка й не червоне: канал справний, незавершене —
|
||||
// особисте налаштування того, хто зараз дивиться.
|
||||
<div className="rounded border border-amber-900/60 bg-amber-950/20 px-3 py-2 text-xs leading-relaxed text-amber-200">
|
||||
Ваш Telegram не прив’язано: повідомлення приходитимуть, але ваші кнопки під
|
||||
ними не спрацюють — підтвердження алерту записується від конкретної людини.
|
||||
Прив’язка — у{' '}
|
||||
<Link to="/profile" className="text-sky-400 hover:underline">
|
||||
профілі
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
|
|
|
|||
133
web/src/test/channelstelegram.test.tsx
Normal file
133
web/src/test/channelstelegram.test.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { ChannelsPage } from '../pages/ChannelsPage'
|
||||
import { session } from '../api/session'
|
||||
import { fetchRouter } from './support'
|
||||
|
||||
/**
|
||||
* Прив'язка Telegram, видима зі сторінки каналів.
|
||||
*
|
||||
* ЧОГО ЦЕЙ ФАЙЛ БОЇТЬСЯ. Канал Telegram заводиться тут, тут же він
|
||||
* успішно проходить «Перевірити», і на цьому людина вважає налаштування
|
||||
* завершеним. Але кнопки «Прийняти» / «Заглушити» під сповіщеннями
|
||||
* особисті: без окремої прив'язки в профілі бот відповідає відмовою.
|
||||
* Виміряний наслідок: у базі нуль прив'язок і нуль кодів за весь час —
|
||||
* цим не скористався НІХТО, включно з власником продукту, який дійшов
|
||||
* до відмови бота й не знайшов із неї виходу.
|
||||
*
|
||||
* Мовчання сторінки тут — не нейтральний стан, а хибне «усе готово».
|
||||
* Тому перевіряються обидві половини:
|
||||
*
|
||||
* - **немає прив'язки** — сказано про наслідок і показано, куди йти;
|
||||
* - **є прив'язка** — сказано, що вона є. Інакше людина, яка вже все
|
||||
* зробила, щоразу питає себе, чи зробила.
|
||||
*
|
||||
* Третя перевірка — про те, чого робити НЕ треба: без жодного
|
||||
* telegram-каналу ні підказки, ні зайвого запиту на кожне відкриття
|
||||
* сторінки.
|
||||
*
|
||||
* Форма підставних відповідей звірена з `server/API.md`
|
||||
* (`GET /api/v1/channels`, `GET /api/v1/me/telegram`) і з типами
|
||||
* `Channel` / `TelegramLink` у `web/src/types.ts`.
|
||||
*/
|
||||
|
||||
const tgChannel = {
|
||||
id: 'ch-tg',
|
||||
kind: 'telegram',
|
||||
name: 'Черговий',
|
||||
config: { chat_id: '-1001234567890' },
|
||||
min_severity: 'warning',
|
||||
enabled: true,
|
||||
has_secret: true,
|
||||
}
|
||||
|
||||
const webhookChannel = {
|
||||
id: 'ch-hook',
|
||||
kind: 'webhook',
|
||||
name: 'Вебхук',
|
||||
config: { url: 'https://hooks.example.com/x' },
|
||||
min_severity: 'warning',
|
||||
enabled: true,
|
||||
has_secret: false,
|
||||
}
|
||||
|
||||
const link = {
|
||||
id: 'tl-1',
|
||||
user_id: 'u-me',
|
||||
tg_user_id: 4242,
|
||||
tg_username: 'duty_admin',
|
||||
tg_name: 'Черговий',
|
||||
linked_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
function show() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChannelsPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('WebSocket', class {})
|
||||
session.set('tok', {
|
||||
userID: 'u-me',
|
||||
username: 'me',
|
||||
tenantID: 't-1',
|
||||
permissions: ['alerts:read', 'alerts:write'],
|
||||
})
|
||||
})
|
||||
|
||||
describe('стан прив’язки Telegram на сторінці каналів', () => {
|
||||
it('без прив’язки називає наслідок і веде в профіль', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/channels': { channels: [tgChannel] },
|
||||
'GET /api/v1/me/telegram': { link: null },
|
||||
})
|
||||
|
||||
show()
|
||||
|
||||
// Саме наслідок, а не «прив'яжіть Telegram»: людина не зобов'язана
|
||||
// знати, що прив'язка взагалі існує, зате добре розуміє, що таке
|
||||
// кнопка, яка не працює.
|
||||
expect(await screen.findByText(/не спрацюють/)).toBeTruthy()
|
||||
|
||||
// Виходу з відмови бота не знайшли саме тому, що його ніде не було
|
||||
// написано. Адреса тут — половина всієї правки.
|
||||
const toProfile = screen.getByRole('link', { name: 'профілі' })
|
||||
expect(toProfile.getAttribute('href')).toBe('/profile')
|
||||
})
|
||||
|
||||
it('з прив’язкою каже про це спокійно й не лякає', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/channels': { channels: [tgChannel] },
|
||||
'GET /api/v1/me/telegram': { link },
|
||||
})
|
||||
|
||||
show()
|
||||
|
||||
// Мовчання читалось би як «не налаштовано» — тобто рівно так само,
|
||||
// як стан без прив'язки, від якого його й треба відрізнити.
|
||||
expect(await screen.findByText(/@duty_admin/)).toBeTruthy()
|
||||
expect(screen.queryByText(/не спрацюють/)).toBeNull()
|
||||
})
|
||||
|
||||
it('без telegram-каналів мовчить і не питає сервер про прив’язку', async () => {
|
||||
const srv = fetchRouter({
|
||||
'GET /api/v1/channels': { channels: [webhookChannel] },
|
||||
// Адресу прив'язки навмисно НЕ описано: якби сторінка сходила по
|
||||
// неї, тест побачив би це і як зайвий виклик, і як 404.
|
||||
})
|
||||
|
||||
show()
|
||||
|
||||
await screen.findByText('Вебхук')
|
||||
await waitFor(() => expect(srv.calls.length).toBeGreaterThan(0))
|
||||
|
||||
expect(screen.queryByText(/не спрацюють/)).toBeNull()
|
||||
expect(screen.queryByText(/Telegram прив/)).toBeNull()
|
||||
// Запит на кожне відкриття сторінки заради підказки, якої не буде.
|
||||
expect(srv.calls.some((c) => c.url.includes('/me/telegram'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { EscalationsPage } from '../pages/EscalationsPage'
|
||||
import { ChannelsPage } from '../pages/ChannelsPage'
|
||||
import { session } from '../api/session'
|
||||
|
|
@ -117,7 +118,14 @@ describe('видалення каналу, на який спирається д
|
|||
},
|
||||
})
|
||||
|
||||
render(<ChannelsPage />)
|
||||
// Router — бо сторінка каналів має посилання в профіль (підказка про
|
||||
// прив'язку Telegram). Тут вона не показується (тест не описав
|
||||
// адресу прив'язки), але покладатись на це не варто.
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChannelsPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
|
||||
|
||||
// Те саме, що вже показує видалення драбини: скільки чужих
|
||||
|
|
@ -128,7 +136,14 @@ describe('видалення каналу, на який спирається д
|
|||
it('без драбин попередження про них не вигадується', async () => {
|
||||
fetchRouter({ 'GET /api/v1/channels': { channels: [duty] } })
|
||||
|
||||
render(<ChannelsPage />)
|
||||
// Router — бо сторінка каналів має посилання в профіль (підказка про
|
||||
// прив'язку Telegram). Тут вона не показується (тест не описав
|
||||
// адресу прив'язки), але покладатись на це не варто.
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ChannelsPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Видалити' }))
|
||||
|
||||
await screen.findByText(/більше не доставлятимуться/)
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ describe('склад меню', () => {
|
|||
// Розгорнутих груп рівно стільки, скільки їх узагалі є: людина, яка
|
||||
// відкрила продукт уперше, має побачити все, що їй доступне, а не
|
||||
// шість закритих шухляд.
|
||||
expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6)
|
||||
expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(7)
|
||||
expect(screen.getByRole('link', { name: /Хости/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -120,7 +120,16 @@ describe('склад меню', () => {
|
|||
expect(screen.queryByRole('button', { name: /^Конфігурації/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Сповіщення/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Адміністрування/ })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(3)
|
||||
|
||||
// А ось «Обліковий запис» лишається ЗАВЖДИ, і це не виняток
|
||||
// з правила, а його межа: власний профіль належить кожному, хто
|
||||
// увійшов, і права його забрати не можуть. Це важливо саме для такого
|
||||
// користувача: там живе прив'язка Telegram, без якої кнопки під
|
||||
// сповіщеннями не працюють, а мережевому інженеру вони потрібні
|
||||
// не менше, ніж власникові.
|
||||
expect(header('Обліковий запис')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Мій профіль/ })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { expanded: true })).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -259,7 +268,7 @@ describe("пам'ять", () => {
|
|||
fetchRouter({ 'GET /api/v1/alerts': alerts() })
|
||||
shell('/devices')
|
||||
|
||||
expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(6)
|
||||
expect(await screen.findAllByRole('button', { expanded: true })).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -269,11 +278,19 @@ describe("пам'ять", () => {
|
|||
|
||||
describe('активна група', () => {
|
||||
it('розгортається сама, а решта згорнутих лишається згорнутою', async () => {
|
||||
// Усі шість згорнуті — так виглядає пам'ять людини, яка звузила
|
||||
// меню до заголовків.
|
||||
// Усі згорнуті — так виглядає пам'ять людини, яка звузила меню
|
||||
// до заголовків.
|
||||
window.localStorage.setItem(
|
||||
GROUPS_KEY,
|
||||
JSON.stringify(['monitoring', 'inventory', 'configs', 'collect', 'notify', 'admin']),
|
||||
JSON.stringify([
|
||||
'monitoring',
|
||||
'inventory',
|
||||
'configs',
|
||||
'collect',
|
||||
'notify',
|
||||
'admin',
|
||||
'account',
|
||||
]),
|
||||
)
|
||||
fetchRouter({ 'GET /api/v1/alerts': alerts() })
|
||||
shell('/devices')
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue