Files
Bridge-and-Join-s/internal/lkgateway/nsdhealth.go
T

597 lines
21 KiB
Go
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.
package lkgateway
// Связь с НРД — «зелёный только при свежем подтверждении». Состояние складывается из участков
// пути, у каждого своё доказательство работы:
// - Шлюз: ИШ отвечает bj (igwwatch.go);
// - НРД: шлюз успешно опрашивает НРД — по журналу ИШ (nsdlinkwatch.go);
// - M2M: сквозная проверка — раз в час и по кнопке «Проверить связь сейчас» bj запрашивает у НРД справочник участников (#M2MHR)
// и ждёт ответ #M2MTH: он проходит весь путь до сервиса M2M и обратно;
// - Отправка: подтверждение доставки каждого пакета (igw.WithDeliveryHook) и недоставленные переводы.
// Отсутствие ошибок само по себе не считается «работает»: нет свежего подтверждения — жёлтый.
// Раз в минуту состояние пишется в историю (сутки), по ней на странице «Статус» рисуется полоса.
// Недоставленные переводы уходят повторно сами, как только НРД снова отвечает (до 24 ч — срок ответа НРД).
import (
"context"
"encoding/json"
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
)
const (
nsdProbeEvery = time.Hour // 22.10.2026: было 10 мин — лишняя нагрузка на НРД; живость между проверками видна по журналу ИШ
nsdProbeWarnAfter = 3 * time.Minute // ответа на проверку нет 3 мин — жёлтый
nsdProbeErrAfter = 10 * time.Minute // 10 мин — красный
nsdPollErrAfter = 3 * time.Minute // шлюз получает от НРД ошибки 3 мин — красный (до этого жёлтый)
nsdPollStale = 5 * time.Minute // нет свежих опросов НРД в журнале — жёлтый
autoResendMaxAge = 24 * time.Hour // срок ответа НРД: старше — не повторяем сами
autoResendGap = 5 * time.Minute // между попытками по одной заявке
nsdHistoryKeep = 24 * 60 // минут
)
var nsdHistoryFile = "/var/lib/bj/.bj/nsd-health.json"
// Состояния участков по возрастанию тяжести.
const (
segOK = "ok"
segUnknown = "unknown"
segWarn = "warn"
segErr = "err"
segOff = "off"
)
func segRank(s string) int {
switch s {
case segOK:
return 0
case segUnknown:
return 1
case segWarn:
return 2
case segErr:
return 3
}
return -1
}
// NSDSeg — участок пути до НРД.
type NSDSeg struct {
Key string `json:"key"`
Title string `json:"title"`
State string `json:"state"`
Text string `json:"text"`
Detail string `json:"detail,omitempty"`
}
// NSDHealth — сводка для шапки, «Статуса» и кабинета клиента.
type NSDHealth struct {
Configured bool `json:"configured"`
State string `json:"state"` // худшее из участков
Segs []NSDSeg `json:"segs"`
LastAnswer time.Time `json:"last_answer"` // последнее подтверждение от НРД (любое)
Ago string `json:"ago"` // «40 с назад»
Failed int `json:"failed"` // недоставленных переводов
}
var nsdH struct {
sync.Mutex
ProbeSentAt time.Time
ProbeReplyAt time.Time
ProbeLatency time.Duration
ProbeErr string
LastDelivered time.Time
LastDelErrAt time.Time
LastDelErr string
probeAlerted bool
resendNotified map[string]bool
history []nsdSample
historyLoaded bool
historyDirtyCnt int
}
type nsdSample struct {
T int64 `json:"t"` // минута (unix/60)
S string `json:"s"` // по символу на участок: o w e u -
}
// nsdProbeReply — пришёл справочник: НРД ответил (по нашей проверке или сам).
func nsdProbeReply(at time.Time) {
nsdH.Lock()
defer nsdH.Unlock()
if !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt) && at.After(nsdH.ProbeSentAt) {
nsdH.ProbeLatency = at.Sub(nsdH.ProbeSentAt)
}
nsdH.ProbeReplyAt = at
}
// nsdDelivery — итог передачи пакета в НРД (из клиента ИШ).
func nsdDelivery(_ string, status, code string) {
nsdH.Lock()
defer nsdH.Unlock()
switch status {
case igw.StatusSent:
nsdH.LastDelivered = time.Now()
case igw.StatusError:
nsdH.LastDelErrAt, nsdH.LastDelErr = time.Now(), code
}
}
func agoText(t time.Time, now time.Time) string {
if t.IsZero() {
return ""
}
d := now.Sub(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%d с назад", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%d мин назад", int(d.Minutes()))
}
return humanDur(d) + " назад"
}
// nsdHealthNow — текущее состояние связи с НРД.
func nsdHealthNow(rc *RuntimeConfig, now time.Time) NSDHealth {
h := NSDHealth{State: segOff}
if rc == nil {
return h
}
base := strings.TrimSpace(rc.Snapshot().NSD.IGWBaseURL)
if base == "" {
return h
}
h.Configured = true
// Шлюз
igwWatch.mu.Lock()
gw := NSDSeg{Key: "igw", Title: "Шлюз"}
igwUp := igwWatch.Checked && igwWatch.Up
switch {
case !igwWatch.Checked:
gw.State, gw.Text = segUnknown, "проверяется"
case igwWatch.Up:
gw.State, gw.Text = segOK, "отвечает"
default:
gw.State, gw.Text, gw.Detail = segErr, "не отвечает "+humanDur(now.Sub(igwWatch.DownSince)), igwWatch.LastErr
}
igwWatch.mu.Unlock()
// НРД (опросы шлюза)
nsdWatch.Lock()
ns := NSDSeg{Key: "nsd", Title: "НРД"}
lastPollOK := nsdWatch.LastPollOK
switch {
case !igwLocal(base):
ns.State, ns.Text = segUnknown, "журнал шлюза недоступен (шлюз на другом сервере)"
case !igwUp:
ns.State, ns.Text = segUnknown, "неизвестно — шлюз не отвечает"
case nsdWatch.LastPollAt.IsZero():
ns.State, ns.Text = segUnknown, "нет данных опроса"
case !nsdWatch.DownSince.IsZero():
down := now.Sub(nsdWatch.DownSince)
ns.State = segWarn
if down >= nsdPollErrAfter {
ns.State = segErr
}
ns.Text = "отвечает шлюзу ошибками " + humanDur(down)
ns.Detail = nsdWatch.Contour + ": " + nsdWatch.LastErr
case now.Sub(nsdWatch.LastPollOK) > nsdPollStale:
ns.State, ns.Text = segWarn, "давно нет опросов НРД (последний "+agoText(nsdWatch.LastPollOK, now)+")"
default:
ns.State, ns.Text = segOK, "отвечает · "+agoText(nsdWatch.LastPollOK, now)
}
nsdWatch.Unlock()
nsdH.Lock()
// M2M — сквозная проверка
m := NSDSeg{Key: "m2m", Title: "M2M"}
pending := !nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.Before(nsdH.ProbeSentAt)
switch {
case nsdH.ProbeSentAt.IsZero() && nsdH.ProbeReplyAt.IsZero():
m.State, m.Text = segUnknown, "проверка ещё не выполнялась"
case nsdH.ProbeErr != "":
m.State, m.Text, m.Detail = segErr, "запрос проверки не доставлен", nsdH.ProbeErr
case pending && now.Sub(nsdH.ProbeSentAt) >= nsdProbeErrAfter:
m.State, m.Text = segErr, "НРД не ответил на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt))
case pending && now.Sub(nsdH.ProbeSentAt) >= nsdProbeWarnAfter:
m.State, m.Text = segWarn, "ждём ответ НРД на проверку "+humanDur(now.Sub(nsdH.ProbeSentAt))
case pending && nsdH.ProbeReplyAt.IsZero():
m.State, m.Text = segUnknown, "проверка отправлена, ждём ответ"
default:
m.State, m.Text = segOK, "ответ на проверку "+agoText(nsdH.ProbeReplyAt, now)
if nsdH.ProbeLatency > 0 {
m.Text += fmt.Sprintf(" (за %d с)", int(nsdH.ProbeLatency.Seconds()))
}
if pending {
m.Text += " · идёт новая проверка"
}
}
// Отправка
sd := NSDSeg{Key: "send", Title: "Отправка"}
h.Failed = sendFailCount()
switch {
case h.Failed > 0:
sd.State, sd.Text = segErr, fmt.Sprintf("не доставлено переводов: %d — отправятся повторно сами, когда НРД ответит", h.Failed)
case !nsdH.LastDelErrAt.IsZero() && nsdH.LastDelErrAt.After(nsdH.LastDelivered):
sd.State, sd.Text, sd.Detail = segWarn, "последний пакет не доставлен "+agoText(nsdH.LastDelErrAt, now), nsdH.LastDelErr
case !nsdH.LastDelivered.IsZero():
sd.State, sd.Text = segOK, "последний пакет доставлен "+agoText(nsdH.LastDelivered, now)
default:
sd.State, sd.Text = segOK, "ошибок доставки нет"
}
for _, t := range []time.Time{lastPollOK, nsdH.ProbeReplyAt, nsdH.LastDelivered} {
if t.After(h.LastAnswer) {
h.LastAnswer = t
}
}
nsdH.Unlock()
h.Segs = []NSDSeg{gw, ns, m, sd}
// Общий цвет — по участкам с известным состоянием: «проверка ещё не выполнялась» не делает
// серым работающий обмен. Серый — только когда по известным участкам связь не подтверждена.
h.State = segOK
for _, s := range h.Segs {
if s.State != segUnknown && segRank(s.State) > segRank(h.State) {
h.State = s.State
}
}
if h.State == segOK && !nsdLinkOK(h) {
h.State = segUnknown
}
h.Ago = agoText(h.LastAnswer, now)
return h
}
// nsdLinkOK — НРД доступен для отправки: шлюз отвечает и (по журналу или проверке) НРД отвечает.
func nsdLinkOK(h NSDHealth) bool {
st := map[string]string{}
for _, s := range h.Segs {
st[s.Key] = s.State
}
if st["igw"] != segOK {
return false
}
switch st["nsd"] {
case segOK:
return true
case segUnknown: // журнала нет — судим по сквозной проверке
return st["m2m"] == segOK
}
return false
}
// --- проверка связи ---
var nsdProbeNowCh = make(chan struct{}, 1)
// nsdProbeNow — «Проверить связь сейчас».
func nsdProbeNow() {
select {
case nsdProbeNowCh <- struct{}{}:
default:
}
}
func (s *Server) sendNSDProbe(ctx context.Context) {
nsdH.Lock()
nsdH.ProbeSentAt, nsdH.ProbeErr = time.Now(), ""
nsdH.Unlock()
cctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
if _, err := sendHandbookRequest(cctx, s.igwClient, s.igwChannel); err != nil {
nsdH.Lock()
nsdH.ProbeErr = err.Error()
nsdH.Unlock()
log.Printf("nsd-health: проверка связи (#M2MHR) не отправлена: %v", err)
}
}
// nsdHealthLoop — проверка раз в час, история раз в минуту, автоповтор недоставленных.
func (s *Server) nsdHealthLoop(ctx context.Context) {
healthRegister("nsd-health", 3*time.Minute)
nsdHistoryLoad()
probeT := time.NewTimer(time.Minute)
defer probeT.Stop()
minute := time.NewTicker(time.Minute)
defer minute.Stop()
for {
select {
case <-ctx.Done():
nsdHistorySave()
return
case <-nsdProbeNowCh:
if s.igwClient != nil {
s.sendNSDProbe(ctx)
}
case <-probeT.C:
if s.igwClient != nil {
s.sendNSDProbe(ctx)
}
probeT.Reset(nsdProbeEvery)
case <-minute.C:
h := nsdHealthNow(s.rc, time.Now())
nsdHistoryAdd(h, time.Now())
s.nsdProbeAlerts(h)
if nsdLinkOK(h) {
s.autoResend(ctx)
}
healthBeat("nsd-health")
}
}
}
// nsdProbeAlerts — оповещения по сквозной проверке (сбой опросов НРД сообщает nsdlinkwatch.go).
func (s *Server) nsdProbeAlerts(h NSDHealth) {
var m NSDSeg
for _, x := range h.Segs {
if x.Key == "m2m" {
m = x
}
}
nsdH.Lock()
was := nsdH.probeAlerted
switch {
case m.State == segErr && !was:
nsdH.probeAlerted = true
case m.State == segOK && was:
nsdH.probeAlerted = false
default:
nsdH.Unlock()
return
}
nsdH.Unlock()
if m.State == segErr {
raiseSystemAlert(s.rc, "НРД не отвечает на проверку M2M", "Сквозная проверка связи (запрос справочника участников M2M) не прошла: "+m.Text+". "+m.Detail+
" Пока так, переводы и решения могут не доходить до НРД. Недоставленные переводы bj отправит повторно сам, когда связь восстановится.")
return
}
raiseSystemAlert(s.rc, "Проверка связи с НРД снова проходит", "НРД ответил на сквозную проверку M2M: "+m.Text+".")
}
// autoResend — повторная отправка недоставленных переводов, когда НРД снова отвечает.
func (s *Server) autoResend(ctx context.Context) {
if s.svc == nil {
return
}
now := time.Now()
for guid, f := range sendFailList() {
first := f.FirstAt
if first.IsZero() {
first = f.At
}
if now.Sub(first) > autoResendMaxAge {
if sendFailGiveUp(guid) {
raiseSystemAlert(s.rc, "Перевод не доставлен в НРД за 24 часа",
fmt.Sprintf("Перевод %s не удалось доставить в НРД за сутки (попыток: %d). Автоповтор остановлен — срок ответа НРД истёк. Решите в карточке заявки: «Отправить повторно» или «Удалить».", shortGUID(guid), f.Attempts))
}
continue
}
if f.GaveUp || now.Sub(f.At) < autoResendGap {
continue
}
cctx, cancel := context.WithTimeout(ctx, time.Minute)
err := s.svc.ResendClaim(cctx, f.DealID)
cancel()
if err != nil {
log.Printf("nsd-health: автоповтор перевода %s: %v", shortGUID(guid), err)
continue
}
log.Printf("nsd-health: перевод %s отправлен повторно автоматически (попытка %d)", shortGUID(guid), f.Attempts+1)
raiseSystemAlert(s.rc, "Перевод отправлен в НРД повторно",
fmt.Sprintf("Связь с НРД восстановилась — перевод %s отправлен автоматически, НРД принял пакет.", shortGUID(guid)))
}
}
// --- история ---
func nsdSegChar(st string) byte {
switch st {
case segOK:
return 'o'
case segWarn:
return 'w'
case segErr:
return 'e'
case segUnknown:
return 'u'
}
return '-'
}
func nsdHistoryLoad() {
nsdH.Lock()
defer nsdH.Unlock()
if nsdH.historyLoaded {
return
}
nsdH.historyLoaded = true
if raw, err := os.ReadFile(nsdHistoryFile); err == nil {
_ = json.Unmarshal(raw, &nsdH.history)
}
}
func nsdHistorySave() {
nsdH.Lock()
raw, err := json.Marshal(nsdH.history)
nsdH.Unlock()
if err == nil {
_ = os.WriteFile(nsdHistoryFile, raw, 0o600)
}
}
func nsdHistoryAdd(h NSDHealth, now time.Time) {
if !h.Configured {
return
}
b := make([]byte, len(h.Segs))
for i, s := range h.Segs {
b[i] = nsdSegChar(s.State)
}
nsdH.Lock()
t := now.Unix() / 60
if n := len(nsdH.history); n > 0 && nsdH.history[n-1].T == t {
nsdH.history[n-1].S = string(b)
} else {
nsdH.history = append(nsdH.history, nsdSample{T: t, S: string(b)})
}
cut := t - nsdHistoryKeep
i := 0
for i < len(nsdH.history) && nsdH.history[i].T <= cut {
i++
}
nsdH.history = nsdH.history[i:]
nsdH.historyDirtyCnt++
save := nsdH.historyDirtyCnt >= 5
if save {
nsdH.historyDirtyCnt = 0
}
nsdH.Unlock()
if save {
nsdHistorySave()
}
}
// NSDStripCell — отрезок полосы (10 минут).
type NSDStripCell struct {
State string
Title string
}
// NSDStripRow — полоса одного участка за сутки.
type NSDStripRow struct {
Title string
Cells []NSDStripCell
Bad string // итог: «сбоев не было» / «сбой 2 ч 10 мин»
}
// nsdStrip — полосы за сутки по 10 минут (худшее состояние в отрезке).
func nsdStrip(titles []string, now time.Time) []NSDStripRow {
nsdHistoryLoad()
nsdH.Lock()
hist := append([]nsdSample(nil), nsdH.history...)
nsdH.Unlock()
const bucket = 10
end := now.Unix() / 60
start := end - nsdHistoryKeep + 1
nb := nsdHistoryKeep / bucket
rows := make([]NSDStripRow, len(titles))
for i, t := range titles {
rows[i] = NSDStripRow{Title: t, Cells: make([]NSDStripCell, nb)}
}
bad := make([]int, len(titles))
for _, smp := range hist {
if smp.T < start || smp.T > end {
continue
}
k := int(smp.T-start) / bucket
for i := range titles {
if i >= len(smp.S) {
continue
}
st := map[byte]string{'o': segOK, 'w': segWarn, 'e': segErr, 'u': segUnknown}[smp.S[i]]
if st == "" {
continue
}
if st == segErr {
bad[i]++
}
if segRank(st) > segRank(rows[i].Cells[k].State) || rows[i].Cells[k].State == "" {
rows[i].Cells[k].State = st
}
}
}
names := map[string]string{segOK: "работает", segWarn: "с перебоями", segErr: "сбой", segUnknown: "нет данных", "": "нет данных"}
for i := range rows {
for k := range rows[i].Cells {
from := time.Unix((start+int64(k*bucket))*60, 0)
c := &rows[i].Cells[k]
if c.State == "" {
c.State = "none"
}
c.Title = from.Format("15:04") + "–" + from.Add(bucket*time.Minute).Format("15:04") + ": " + names[strings.TrimPrefix(c.State, "none")]
}
if bad[i] == 0 {
rows[i].Bad = "сбоев не было"
} else {
rows[i].Bad = "сбой " + humanDur(time.Duration(bad[i])*time.Minute)
}
}
return rows
}
// --- HTTP ---
// registerNSDHealth — GET /admin/nsd-health.json (шапка обновляется сама), POST /admin/status/nsd-probe.
func registerNSDHealth(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/nsd-health.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(nsdHealthNow(rc, time.Now()))
})
mux.HandleFunc("/admin/status/nsd-probe", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
nsdProbeNow()
http.Redirect(w, r, "/admin/status?probe=1#nsd", http.StatusSeeOther)
})
}
// nsdClientNotice — текст для кабинета клиента (пусто — обмен работает).
func nsdClientNotice(h NSDHealth) string {
if !h.Configured || nsdLinkOK(h) {
return ""
}
for _, s := range h.Segs {
if (s.Key == "igw" || s.Key == "nsd" || s.Key == "m2m") && s.State == segErr {
return "Обмен с НРД временно недоступен. Поручения на перевод принимаются и будут отправлены в НРД автоматически, как только связь восстановится."
}
}
return ""
}
// nsdSegPop — всплывающая панель цепочки в шапке: участки с цветной точкой. Строки «последний ответ»
// нет — время ответа уже в участке «НРД».
func nsdSegPop(h NSDHealth) template.HTML {
esc := template.HTMLEscapeString
var b strings.Builder
b.WriteString(`<span class="nsd-pop" role="tooltip"><span class="nsd-pop-b">`)
for _, s := range h.Segs {
b.WriteString(`<span class="np-row"><i class="np-d ` + esc(s.State) + `"></i><b>` + esc(s.Title) + `</b><span>` + esc(s.Text))
if s.Detail != "" {
b.WriteString(`<small>` + esc(s.Detail) + `</small>`)
}
b.WriteString(`</span></span>`)
}
b.WriteString(`<span class="np-foot">Подробно — «Статус»</span></span></span>`)
return template.HTML(b.String())
}
// nsdSegTip — подсказка к цепочке в шапке: все участки построчно.
func nsdSegTip(h NSDHealth) string {
var b strings.Builder
for _, s := range h.Segs {
b.WriteString(s.Title + ": " + s.Text)
if s.Detail != "" {
b.WriteString(" (" + s.Detail + ")")
}
b.WriteString("\n")
}
if h.Ago != "" {
b.WriteString("Последний ответ НРД " + h.Ago + "\n")
} else {
b.WriteString("Ответа НРД ещё не было\n")
}
b.WriteString("Подробно — «Статус». Обновляется каждые 30 с.")
return b.String()
}