Мониторинг связи с НРД: участки Шлюз/НРД/M2M/Отправка, сквозная проверка справочником раз в 10 мин, история за сутки на «Статусе», цепочка в шапке (30 с), автоповтор недоставленных до 24 ч, баннер и статус «ожидает отправки» в кабинете клиента
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -52,6 +52,7 @@ var templateFuncs = template.FuncMap{
|
||||
"curUser": authTemplateFuncs["curUser"],
|
||||
"can": authTemplateFuncs["can"],
|
||||
"sendFailText": sendFailText,
|
||||
"nsdSegTip": nsdSegTip,
|
||||
"devOn": authTemplateFuncs["devOn"],
|
||||
"devLicensed": authTemplateFuncs["devLicensed"],
|
||||
"mod": func(f string) bool { return globalRC == nil || moduleAllowed(globalRC, f) }, // модуль в лицензии
|
||||
@@ -361,8 +362,9 @@ type page struct {
|
||||
NPR bool // модуль «НПР / УДС» включён и разрешён лицензией
|
||||
IGWDown string // ИШ НРД не отвечает: «N мин» (пусто — отвечает или не проверялся)
|
||||
IGWDetail string
|
||||
NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go)
|
||||
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
|
||||
NSDDown string // ИШ отвечает, но НРД не отвечает шлюзу (nsdlinkwatch.go)
|
||||
NSDLink NSDHealth // цепочка «Шлюз · НРД · M2M · Отправка» для шапки (nsdhealth.go)
|
||||
DevMode bool // режим разработки (сервер лицензий): видны служебные разделы
|
||||
}
|
||||
|
||||
// globalRC — ссылка на runtime-конфиг для template-funcs/page helpers.
|
||||
@@ -417,6 +419,10 @@ type statusData struct {
|
||||
page
|
||||
Checks []Status
|
||||
CheckedAt time.Time
|
||||
Strip []NSDStripRow // связь с НРД за сутки (nsdhealth.go)
|
||||
StripFrom string // начало полосы
|
||||
Fails []SendFailView // недоставленные переводы
|
||||
Probe bool // только что запрошена проверка связи
|
||||
}
|
||||
|
||||
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
|
||||
@@ -786,8 +792,16 @@ func (a *admin) renderHandbook(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) {
|
||||
s := CheckAll(r.Context(), opts)
|
||||
pg := nowPage("Статус", "status")
|
||||
var titles []string
|
||||
for _, sg := range pg.NSDLink.Segs {
|
||||
titles = append(titles, sg.Title)
|
||||
}
|
||||
now := time.Now()
|
||||
render(w, a.status, statusData{
|
||||
page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt,
|
||||
page: pg, Checks: s.Checks, CheckedAt: s.CheckedAt,
|
||||
Strip: nsdStrip(titles, now), StripFrom: now.Add(-24 * time.Hour).Format("02.01 15:04"),
|
||||
Fails: sendFailViews(), Probe: r.URL.Query().Get("probe") != "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -805,6 +819,7 @@ func nowPage(title, active string) page {
|
||||
p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
|
||||
if globalRC != nil {
|
||||
p.NPR = nprAvailable(globalRC)
|
||||
p.NSDLink = nsdHealthNow(globalRC, time.Now())
|
||||
p.DevMode = devMode(globalRC)
|
||||
if down, d, det := igwStatus(); down {
|
||||
p.IGWDown, p.IGWDetail = humanDur(d), det
|
||||
|
||||
@@ -21,9 +21,9 @@ import (
|
||||
|
||||
// LiveStatus — состояние для показа.
|
||||
type LiveStatus struct {
|
||||
Text string `json:"text"` // чего ждём / чем закончилось
|
||||
Kind string `json:"kind"` // wait | warn | ok | bad
|
||||
Active bool `json:"active"` // в работе: страница продолжает опрашивать
|
||||
Text string `json:"text"` // чего ждём / чем закончилось
|
||||
Kind string `json:"kind"` // wait | warn | ok | bad
|
||||
Active bool `json:"active"` // в работе: страница продолжает опрашивать
|
||||
Sig string `json:"sig,omitempty"` // отпечаток состояния: изменился — карточка перерисовывается
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func liveOutgoingState(c ClaimView) LiveStatus {
|
||||
watch = globalOrderWatch.Get(guid)
|
||||
}
|
||||
if f := sendFailGet(guid); f != nil && guid != "" {
|
||||
return LiveStatus{Text: "не доставлена в НРД — " + sendFailText(f) + "; нужна повторная отправка", Kind: "err", Active: true}
|
||||
return LiveStatus{Text: "не доставлена в НРД — " + sendFailText(f) + "; отправится повторно сама, когда НРД ответит", Kind: "err", Active: true}
|
||||
}
|
||||
switch c.Status {
|
||||
case "draft", "validated":
|
||||
|
||||
@@ -596,6 +596,15 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) {
|
||||
lkJSON(w, http.StatusNotFound, map[string]string{"error": "клиент не найден"})
|
||||
})
|
||||
|
||||
// Состояние обмена с НРД для баннера кабинета: пусто — работает (nsdhealth.go).
|
||||
mux.HandleFunc("/api/lk/nsd-status", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
}
|
||||
h := nsdHealthNow(rc, time.Now())
|
||||
lkJSON(w, http.StatusOK, map[string]any{"ok": nsdClientNotice(h) == "", "notice": nsdClientNotice(h)})
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/lk/transfer", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !auth(w, r) {
|
||||
return
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -330,3 +331,17 @@ func lkClaimsByContract(ctx context.Context, rc *RuntimeConfig, contract string,
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// lkQueuedToSent — перевод, не доходивший до НРД, доставлен: заявка клиента «ожидает отправки»
|
||||
// становится «отправлена». Заявки не из модуля ЛК молча пропускаются.
|
||||
func lkQueuedToSent(rc *RuntimeConfig, guid string) {
|
||||
db, err := lkDB(rc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
||||
defer cancel()
|
||||
if _, err := db.ExecContext(ctx, "UPDATE lk.transfer_requests SET status='sent' WHERE lower(guid)=lower($1) AND status='queued'", guid); err != nil {
|
||||
log.Printf("lk-gateway: заявка ЛК GUID=%s → отправлена: %v", guid, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
|
||||
)
|
||||
|
||||
@@ -71,6 +72,8 @@ func lkStatusRu(s string) string {
|
||||
return "На проверке"
|
||||
case "ready":
|
||||
return "Проверена, отправляется в НРД"
|
||||
case "queued":
|
||||
return "Принята, ожидает отправки в НРД"
|
||||
case "sent":
|
||||
return "Отправлена в НРД"
|
||||
case "rejected":
|
||||
@@ -246,6 +249,16 @@ func (s *Service) CreateLKTransfer(ctx context.Context, rc *RuntimeConfig, u LKU
|
||||
sender = lkEmuSender
|
||||
}
|
||||
if _, err := s.SendBuiltRequestVia(ctx, sender, req, lkInvestorUUID(u.Contract), nil); err != nil {
|
||||
var nd *igw.NotDeliveredError
|
||||
if errors.As(err, &nd) {
|
||||
// НРД сейчас не принимает пакеты: заявка сохранена и уйдёт сама, когда связь восстановится
|
||||
// (nsdhealth.go, автоповтор) — клиенту это не ошибка.
|
||||
_ = lkMarkStatus(ctx, rc, id, "queued")
|
||||
return LKTransferResult{
|
||||
OK: true, RequestID: id, GUID: string(req.Header.GUID), Status: "queued",
|
||||
Message: "Заявка принята. Обмен с НРД сейчас временно недоступен — заявка будет отправлена автоматически, как только связь восстановится. Статус — в разделе «Мои заявки».",
|
||||
}, nil
|
||||
}
|
||||
_ = lkMarkStatus(ctx, rc, id, "error")
|
||||
return res, fmt.Errorf("заявка сохранена, но не отправлена: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
package lkgateway
|
||||
|
||||
// Связь с НРД — «зелёный только при свежем подтверждении». Состояние складывается из участков
|
||||
// пути, у каждого своё доказательство работы:
|
||||
// - Шлюз: ИШ отвечает bj (igwwatch.go);
|
||||
// - НРД: шлюз успешно опрашивает НРД — по журналу ИШ (nsdlinkwatch.go);
|
||||
// - M2M: сквозная проверка — раз в 10 минут bj запрашивает у НРД справочник участников (#M2MHR)
|
||||
// и ждёт ответ #M2MTH: он проходит весь путь до сервиса M2M и обратно;
|
||||
// - Отправка: подтверждение доставки каждого пакета (igw.WithDeliveryHook) и недоставленные переводы.
|
||||
// Отсутствие ошибок само по себе не считается «работает»: нет свежего подтверждения — жёлтый.
|
||||
// Раз в минуту состояние пишется в историю (сутки), по ней на странице «Статус» рисуется полоса.
|
||||
// Недоставленные переводы уходят повторно сами, как только НРД снова отвечает (до 24 ч — срок ответа НРД).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw"
|
||||
)
|
||||
|
||||
const (
|
||||
nsdProbeEvery = 10 * time.Minute
|
||||
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 segRank(s.State) > segRank(h.State) {
|
||||
h.State = s.State
|
||||
}
|
||||
}
|
||||
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 — проверка раз в 10 мин, история раз в минуту, автоповтор недоставленных.
|
||||
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 ""
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
b.WriteString("Подробно — «Статус». Обновляется каждые 30 с.")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
)
|
||||
|
||||
func nsdTestRC(t *testing.T) *RuntimeConfig {
|
||||
t.Helper()
|
||||
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := rc.Snapshot().NSD
|
||||
n.IGWBaseURL = "http://localhost:8090"
|
||||
if err := rc.UpdateNSD(n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
func nsdTestReset(t *testing.T) {
|
||||
t.Helper()
|
||||
old := sendFailFile
|
||||
sendFailFile = filepath.Join(t.TempDir(), "sf.json")
|
||||
sendFails.loaded = false
|
||||
oldHist := nsdHistoryFile
|
||||
nsdHistoryFile = filepath.Join(t.TempDir(), "h.json")
|
||||
t.Cleanup(func() {
|
||||
sendFailFile, sendFails.loaded, nsdHistoryFile = old, false, oldHist
|
||||
igwWatch.mu.Lock()
|
||||
igwWatch.Checked, igwWatch.Up = false, false
|
||||
igwWatch.mu.Unlock()
|
||||
nsdWatch.Lock()
|
||||
nsdWatch.DownSince, nsdWatch.LastPollAt, nsdWatch.LastPollOK = time.Time{}, time.Time{}, time.Time{}
|
||||
nsdWatch.Unlock()
|
||||
nsdH.Lock()
|
||||
nsdH.ProbeSentAt, nsdH.ProbeReplyAt, nsdH.ProbeErr, nsdH.history, nsdH.historyLoaded = time.Time{}, time.Time{}, "", nil, false
|
||||
nsdH.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func segState(h NSDHealth, key string) string {
|
||||
for _, s := range h.Segs {
|
||||
if s.Key == key {
|
||||
return s.State
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Зелёный — только при свежих подтверждениях; сбой опросов и молчание на проверку — красный.
|
||||
func TestNSDHealthStates(t *testing.T) {
|
||||
nsdTestReset(t)
|
||||
rc := nsdTestRC(t)
|
||||
now := time.Now()
|
||||
igwWatch.mu.Lock()
|
||||
igwWatch.Checked, igwWatch.Up = true, true
|
||||
igwWatch.mu.Unlock()
|
||||
nsdWatch.Lock()
|
||||
nsdWatch.LastPollAt, nsdWatch.LastPollOK = now.Add(-10*time.Second), now.Add(-10*time.Second)
|
||||
nsdWatch.Unlock()
|
||||
nsdH.Lock()
|
||||
nsdH.ProbeSentAt, nsdH.ProbeReplyAt = now.Add(-5*time.Minute), now.Add(-4*time.Minute)
|
||||
nsdH.Unlock()
|
||||
|
||||
h := nsdHealthNow(rc, now)
|
||||
if h.State != segOK || !nsdLinkOK(h) || nsdClientNotice(h) != "" || h.Ago == "" {
|
||||
t.Fatalf("всё работает: %+v", h)
|
||||
}
|
||||
|
||||
nsdWatch.Lock()
|
||||
nsdWatch.DownSince, nsdWatch.LastErr = now.Add(-5*time.Minute), "#404"
|
||||
nsdWatch.Unlock()
|
||||
h = nsdHealthNow(rc, now)
|
||||
if segState(h, "nsd") != segErr || h.State != segErr || nsdLinkOK(h) || nsdClientNotice(h) == "" {
|
||||
t.Fatalf("НРД отвечает ошибками 5 мин: %+v", h)
|
||||
}
|
||||
|
||||
nsdWatch.Lock()
|
||||
nsdWatch.DownSince = time.Time{}
|
||||
nsdWatch.LastPollOK = now.Add(-20 * time.Minute) // тишина — не «работает»
|
||||
nsdWatch.Unlock()
|
||||
if h = nsdHealthNow(rc, now); segState(h, "nsd") != segWarn {
|
||||
t.Fatalf("давно нет опросов: %+v", h)
|
||||
}
|
||||
|
||||
nsdWatch.Lock()
|
||||
nsdWatch.LastPollOK = now
|
||||
nsdWatch.Unlock()
|
||||
nsdH.Lock()
|
||||
nsdH.ProbeSentAt = now.Add(-12 * time.Minute)
|
||||
nsdH.ProbeReplyAt = now.Add(-25 * time.Minute)
|
||||
nsdH.Unlock()
|
||||
if h = nsdHealthNow(rc, now); segState(h, "m2m") != segErr {
|
||||
t.Fatalf("НРД молчит на проверку 12 мин: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Автоповтор: НРД снова отвечает — недоставленный перевод уходит сам; старше суток — не трогаем.
|
||||
func TestAutoResend(t *testing.T) {
|
||||
nsdTestReset(t)
|
||||
oldHook, oldQ := sentHook, quikSendHook
|
||||
defer func() { sentHook, quikSendHook = oldHook, oldQ }()
|
||||
sentHook, quikSendHook = nil, nil
|
||||
|
||||
ctx := context.Background()
|
||||
snd := &flakySender{fail: true}
|
||||
svc := NewService(Config{Repository: m2mcore.NewMemoryRepository(), Sender: snd})
|
||||
s := &Server{svc: svc}
|
||||
req := sendFailRequest()
|
||||
if _, err := svc.SendBuiltRequest(ctx, req, "inv", nil); err == nil {
|
||||
t.Fatal("ожидалась ошибка доставки")
|
||||
}
|
||||
guid := string(req.Header.GUID)
|
||||
|
||||
s.autoResend(ctx) // последняя попытка только что — ждём 5 мин
|
||||
if sendFailGet(guid) == nil {
|
||||
t.Fatal("рано повторять")
|
||||
}
|
||||
sendFails.Lock()
|
||||
f := sendFails.m[guid]
|
||||
f.At = time.Now().Add(-6 * time.Minute)
|
||||
sendFails.m[guid] = f
|
||||
sendFails.Unlock()
|
||||
|
||||
snd.fail = false
|
||||
s.autoResend(ctx)
|
||||
if sendFailGet(guid) != nil {
|
||||
t.Fatal("перевод должен уйти автоматически")
|
||||
}
|
||||
d, _ := svc.Repo().GetByGUID(ctx, req.Header.GUID)
|
||||
if d.State != m2mcore.StateAwaitingDecision {
|
||||
t.Fatalf("после автоповтора: %s", d.State)
|
||||
}
|
||||
|
||||
// Старше суток — автоповтор останавливается.
|
||||
sendFails.Lock()
|
||||
sendFails.m["old"] = SendFailure{DealID: "x", At: time.Now().Add(-26 * time.Hour), FirstAt: time.Now().Add(-26 * time.Hour), Attempts: 9}
|
||||
sendFails.Unlock()
|
||||
s.autoResend(ctx)
|
||||
if f := sendFailGet("old"); f == nil || !f.GaveUp {
|
||||
t.Fatalf("старше суток: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNSDStripAndStatusRender(t *testing.T) {
|
||||
nsdTestReset(t)
|
||||
now := time.Now()
|
||||
nsdH.Lock()
|
||||
nsdH.historyLoaded = true
|
||||
nsdH.Unlock()
|
||||
for i := 30; i >= 0; i-- {
|
||||
st := segOK
|
||||
if i < 5 {
|
||||
st = segErr
|
||||
}
|
||||
nsdHistoryAdd(NSDHealth{Configured: true, Segs: []NSDSeg{{State: segOK}, {State: st}, {State: segOK}, {State: segOK}}}, now.Add(-time.Duration(i)*time.Minute))
|
||||
}
|
||||
rows := nsdStrip([]string{"Шлюз", "НРД", "M2M", "Отправка"}, now)
|
||||
if len(rows) != 4 || len(rows[1].Cells) != 144 || rows[1].Cells[143].State != segErr || rows[0].Bad != "сбоев не было" || !strings.HasPrefix(rows[1].Bad, "сбой") {
|
||||
t.Fatalf("полоса: %+v / %q", rows[1].Cells[143], rows[1].Bad)
|
||||
}
|
||||
|
||||
a, err := newAdmin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pg := page{Title: "Статус"}
|
||||
pg.NSDLink = NSDHealth{Configured: true, State: segErr, Segs: []NSDSeg{{Key: "igw", Title: "Шлюз", State: segOK, Text: "отвечает"}, {Key: "nsd", Title: "НРД", State: segErr, Text: "отвечает шлюзу ошибками"}}}
|
||||
var b bytes.Buffer
|
||||
data := statusData{page: pg, Strip: rows, Fails: []SendFailView{{GUID: "d7a182d4-4f79", DealID: "7f1c5cfb", Text: "контур НРД не принял пакет", FirstAt: now, Attempts: 2}}}
|
||||
if err := a.status.ExecuteTemplate(&b, "layout", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"Связь с НРД", "nsd-cells", "Не доставлены в НРД", "Проверить связь сейчас", "id=\"nsd-chain\"", "отправится сам"} {
|
||||
if !strings.Contains(b.String(), want) {
|
||||
t.Fatalf("нет %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,8 @@ var nsdWatch struct {
|
||||
Contour string
|
||||
Alerted bool
|
||||
LastRemind time.Time
|
||||
LastPollAt time.Time // последний опрос НРД шлюзом (по журналу)
|
||||
LastPollOK time.Time // последний успешный
|
||||
}
|
||||
|
||||
// nsdLinkStatus — для страницы статуса: не отвечает ли НРД шлюзу.
|
||||
@@ -109,6 +111,19 @@ func nsdLinkTick(rc *RuntimeConfig, now time.Time) {
|
||||
}
|
||||
w := &nsdWatch
|
||||
w.Lock()
|
||||
w.LastPollAt = last.At
|
||||
if last.OK {
|
||||
w.LastPollOK = last.At
|
||||
} else if firstFail != nil && w.LastPollOK.IsZero() {
|
||||
// в хвосте журнала нет успешного опроса после сбоя — ищем последний успешный до него
|
||||
for _, ln := range strings.Split(string(tail), "\n") {
|
||||
if m := nsdPollRe.FindStringSubmatch(strings.TrimRight(ln, "\r")); m != nil && m[3] == "OK" {
|
||||
if at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local); err == nil {
|
||||
w.LastPollOK = at
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if last.OK {
|
||||
since, alerted := w.DownSince, w.Alerted
|
||||
w.DownSince, w.LastErr, w.Alerted = time.Time{}, "", false
|
||||
|
||||
@@ -253,13 +253,17 @@ func (s *Server) applyIncoming(ctx context.Context, p igw.Package) error {
|
||||
log.Printf("lk-gateway: поллер справочник M2M id=%d: разбор: %v", p.ID, err)
|
||||
return nil
|
||||
}
|
||||
saveHandbook(&hb)
|
||||
nsdProbeReply(time.Now())
|
||||
if !saveHandbook(&hb) {
|
||||
log.Printf("lk-gateway: поллер справочник M2M id=%d: без изменений (%d участников) — связь с НРД подтверждена", p.ID, len(hb.Participants.Participants))
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
if err := depoWriteHandbook(s.rc); err != nil {
|
||||
log.Printf("depo-events: справочник после получения: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d",
|
||||
log.Printf("lk-gateway: поллер СПРАВОЧНИК M2M получен id=%d: участников %d, мест расчётов %d (состав изменился)",
|
||||
p.ID, len(hb.Participants.Participants), len(hb.SettlementPlaces.Places))
|
||||
return nil
|
||||
default:
|
||||
@@ -300,8 +304,10 @@ type HandbookEntry struct {
|
||||
EngName string `json:"eng_name,omitempty"`
|
||||
}
|
||||
|
||||
// saveHandbook раскладывает справочник в плоский список и пишет в файл.
|
||||
func saveHandbook(hb *m2m.M2MTransferHandbook) {
|
||||
// saveHandbook раскладывает справочник в плоский список и пишет в файл. Возвращает, изменился ли
|
||||
// состав: справочник запрашивается раз в 10 минут (сквозная проверка связи, nsdhealth.go), и без
|
||||
// изменений его не нужно ни рассылать, ни переписывать в DEPO.
|
||||
func saveHandbook(hb *m2m.M2MTransferHandbook) (changed bool) {
|
||||
out := struct {
|
||||
At string `json:"at"`
|
||||
Participants []HandbookEntry `json:"participants"`
|
||||
@@ -324,13 +330,30 @@ func saveHandbook(hb *m2m.M2MTransferHandbook) {
|
||||
e.Code, e.Kind = string(p.BrokerPlace.ParticipantCode), "broker"
|
||||
}
|
||||
out.Participants = append(out.Participants, e)
|
||||
log.Printf("lk-gateway: справочник M2M: ИНН %s → %s [%s] %s", e.INN, e.Code, e.Kind, e.Name)
|
||||
}
|
||||
for _, pl := range hb.SettlementPlaces.Places {
|
||||
out.Places = append(out.Places, HandbookEntry{INN: string(pl.INN), Name: pl.ShortName, DisplayName: pl.DisplayName, Kind: "place"})
|
||||
}
|
||||
changed = true
|
||||
if old, err := os.ReadFile(handbookFile); err == nil {
|
||||
var prev struct {
|
||||
Participants []HandbookEntry `json:"participants"`
|
||||
Places []HandbookEntry `json:"settlement_places"`
|
||||
}
|
||||
if json.Unmarshal(old, &prev) == nil {
|
||||
a, _ := json.Marshal([]any{prev.Participants, prev.Places})
|
||||
b, _ := json.Marshal([]any{out.Participants, out.Places})
|
||||
changed = string(a) != string(b)
|
||||
}
|
||||
}
|
||||
if raw, err := json.Marshal(out); err == nil {
|
||||
_ = os.WriteFile(handbookFile, raw, 0o600)
|
||||
pushHandbookAsync(globalRC, raw) // свежий справочник — на сервер лицензий для всех клиентов
|
||||
if changed {
|
||||
for _, e := range out.Participants {
|
||||
log.Printf("lk-gateway: справочник M2M: ИНН %s → %s [%s] %s", e.INN, e.Code, e.Kind, e.Name)
|
||||
}
|
||||
pushHandbookAsync(globalRC, raw) // свежий справочник — на сервер лицензий для всех клиентов
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -26,8 +27,10 @@ type SendFailure struct {
|
||||
PackageID string `json:"package_id,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Error string `json:"error"`
|
||||
At time.Time `json:"at"`
|
||||
At time.Time `json:"at"` // последняя попытка
|
||||
FirstAt time.Time `json:"first_at"` // первая попытка: автоповтор — не дольше суток от неё
|
||||
Attempts int `json:"attempts"`
|
||||
GaveUp bool `json:"gave_up,omitempty"` // сутки прошли — автоповтор остановлен
|
||||
}
|
||||
|
||||
var sendFailFile = "/var/lib/bj/.bj/send-failures.json"
|
||||
@@ -72,6 +75,9 @@ func sendFailNote(guid, dealID string, err error) SendFailure {
|
||||
sendFailLoad()
|
||||
f := sendFails.m[guid]
|
||||
f.DealID, f.Error, f.At, f.Attempts = dealID, err.Error(), time.Now(), f.Attempts+1
|
||||
if f.FirstAt.IsZero() {
|
||||
f.FirstAt = f.At
|
||||
}
|
||||
f.PackageID, f.Code = "", ""
|
||||
var nd *igw.NotDeliveredError
|
||||
if errors.As(err, &nd) {
|
||||
@@ -82,14 +88,50 @@ func sendFailNote(guid, dealID string, err error) SendFailure {
|
||||
return f
|
||||
}
|
||||
|
||||
// sendFailClear — перевод доставлен. Если до этого он не доходил — заявка клиента из «ожидает
|
||||
// отправки» переходит в «отправлена».
|
||||
func sendFailClear(guid string) {
|
||||
sendFails.Lock()
|
||||
defer sendFails.Unlock()
|
||||
sendFailLoad()
|
||||
if _, ok := sendFails.m[guid]; ok {
|
||||
_, ok := sendFails.m[guid]
|
||||
if !sendFails.loaded {
|
||||
sendFailLoad()
|
||||
_, ok = sendFails.m[guid]
|
||||
}
|
||||
if ok {
|
||||
delete(sendFails.m, guid)
|
||||
sendFailSave()
|
||||
}
|
||||
sendFails.Unlock()
|
||||
if ok && globalRC != nil {
|
||||
go lkQueuedToSent(globalRC, guid)
|
||||
}
|
||||
}
|
||||
|
||||
// sendFailList — копия всех недоставленных (GUID → неудача).
|
||||
func sendFailList() map[string]SendFailure {
|
||||
sendFails.Lock()
|
||||
defer sendFails.Unlock()
|
||||
sendFailLoad()
|
||||
out := make(map[string]SendFailure, len(sendFails.m))
|
||||
for k, v := range sendFails.m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sendFailGiveUp — остановить автоповтор (true — только что остановлен, нужно сообщить).
|
||||
func sendFailGiveUp(guid string) bool {
|
||||
sendFails.Lock()
|
||||
defer sendFails.Unlock()
|
||||
sendFailLoad()
|
||||
f, ok := sendFails.m[guid]
|
||||
if !ok || f.GaveUp {
|
||||
return false
|
||||
}
|
||||
f.GaveUp = true
|
||||
sendFails.m[guid] = f
|
||||
sendFailSave()
|
||||
return true
|
||||
}
|
||||
|
||||
// noteSendFailure — записать неудачу, событие сделки и оповещение.
|
||||
@@ -97,10 +139,13 @@ func (s *Service) noteSendFailure(ctx context.Context, deal *m2mcore.Deal, req *
|
||||
guid := string(req.Header.GUID)
|
||||
f := sendFailNote(guid, deal.ID, err)
|
||||
_ = s.repo.AppendEvent(ctx, deal.ID, m2mcore.Event{Type: "send_failed", Payload: f, CreatedAt: time.Now().UTC(), Actor: "system"})
|
||||
log.Printf("lk-gateway: перевод %s (заявка %s) НЕ доставлен в НРД: %v", shortGUID(guid), shortID(deal.ID), err)
|
||||
log.Printf("lk-gateway: перевод %s (заявка %s) НЕ доставлен в НРД (попытка %d): %v", shortGUID(guid), shortID(deal.ID), f.Attempts, err)
|
||||
if f.Attempts > 1 { // о повторных неудачах автоповтора не шумим — сообщили при первой
|
||||
return
|
||||
}
|
||||
inv := req.Data.InvestorInformation
|
||||
raiseSystemAlert(globalRC, "Перевод не доставлен в НРД",
|
||||
fmt.Sprintf("Перевод %s (%s %s): %v. Заявка осталась «Не отправлена», в DEPO отправка не отмечена. Когда НРД снова будет доступен — «Отправить повторно» в карточке заявки.",
|
||||
fmt.Sprintf("Перевод %s (%s %s): %v. Заявка осталась «Не отправлена», в DEPO отправка не отмечена. bj отправит её повторно сам, как только НРД снова ответит (в течение суток); можно и вручную — «Отправить повторно» в карточке заявки.",
|
||||
shortGUID(guid), inv.LastName, inv.FirstName, err))
|
||||
}
|
||||
|
||||
@@ -162,3 +207,24 @@ func sendFailText(f *SendFailure) string {
|
||||
}
|
||||
return f.Error
|
||||
}
|
||||
|
||||
// SendFailView — строка «недоставленные переводы» на странице «Статус».
|
||||
type SendFailView struct {
|
||||
GUID, DealID, Text string
|
||||
At, FirstAt time.Time
|
||||
Attempts int
|
||||
GaveUp bool
|
||||
}
|
||||
|
||||
func sendFailViews() []SendFailView {
|
||||
var out []SendFailView
|
||||
for guid, f := range sendFailList() {
|
||||
first := f.FirstAt
|
||||
if first.IsZero() {
|
||||
first = f.At
|
||||
}
|
||||
out = append(out, SendFailView{GUID: guid, DealID: f.DealID, Text: sendFailText(&f), At: f.At, FirstAt: first, Attempts: f.Attempts, GaveUp: f.GaveUp})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].FirstAt.Before(out[j].FirstAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
|
||||
@@ -117,5 +117,4 @@ func sendFailRequest() *m2m.M2MTransferRequest {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func waitShort() { time.Sleep(10 * time.Millisecond) }
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
|
||||
@@ -100,7 +99,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
log.Printf("lk-gateway: профиль ИШ %q неизвестен (%v) — fallback mock", s.NSD.Profile, perr)
|
||||
} else {
|
||||
prof.IGWBaseURL = s.NSD.IGWBaseURL // override URL из setup.json
|
||||
cl := igw.NewClient(s.NSD.IGWBaseURL)
|
||||
cl := igw.NewClient(s.NSD.IGWBaseURL, igw.WithDeliveryHook(nsdDelivery))
|
||||
sender = nsdadapter.NewSender(prof, igwClientAdapter{c: cl})
|
||||
igwClient = cl
|
||||
// Канал ИШ резолвится по составному коду <канал>+<депонент>.
|
||||
@@ -215,6 +214,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
registerOutgoing(mux, adminTpl, outDeps)
|
||||
registerManual(mux, rc, svc) // ручной ввод исходящих переводов по бумажным поручениям
|
||||
registerJournal(mux, adminTpl, svc, incomingStore)
|
||||
registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
|
||||
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
|
||||
registerHealth(mux)
|
||||
registerQuikOps(mux, rc, svc)
|
||||
@@ -322,7 +322,7 @@ func (s *Server) Run(ctx context.Context) error {
|
||||
|
||||
// Авто-обновление справочника участников M2M раз в сутки (только реальный
|
||||
// ИШ). Тик суточный — в heartbeat-монитор не регистрируем, только recover.
|
||||
go superviseLoop(ctx, "handbook-refresh", s.rc, s.handbookRefreshLoop)
|
||||
go superviseLoop(ctx, "nsd-health", s.rc, s.nsdHealthLoop) // связь с НРД: проверка раз в 10 мин (справочник), история, автоповтор
|
||||
|
||||
// QUIK МНП: автоматический цикл M2M (регистрация до отправки — хук в
|
||||
// SendBuiltRequest; подпись/снятие/зачисление по MS010 — опросчик).
|
||||
@@ -470,39 +470,6 @@ func registerHandbookRequest(mux *http.ServeMux, igwClient *igw.Client, channel
|
||||
})
|
||||
}
|
||||
|
||||
// handbookRefreshLoop раз в сутки обновляет справочник участников M2M, чтобы он
|
||||
// оставался актуальным. Первый запрос — вскоре после старта, если справочника
|
||||
// ещё нет или он старше суток. Работает только в реальном режиме ИШ.
|
||||
func (s *Server) handbookRefreshLoop(ctx context.Context) {
|
||||
if s.igwClient == nil || s.igwChannel == "" {
|
||||
<-ctx.Done() // нечего делать в mock-режиме, но не выходим (иначе supervise перезапустит)
|
||||
return
|
||||
}
|
||||
const interval = 24 * time.Hour
|
||||
initial := time.Minute
|
||||
if fi, err := os.Stat(handbookFile); err == nil && time.Since(fi.ModTime()) < interval {
|
||||
initial = interval - time.Since(fi.ModTime()) // справочник свежий — ждём до суток
|
||||
}
|
||||
timer := time.NewTimer(initial)
|
||||
defer timer.Stop()
|
||||
log.Printf("lk-gateway: авто-обновление справочника M2M включено (интервал %s, первый запрос через %s)", interval, initial.Round(time.Second))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
if id, err := sendHandbookRequest(cctx, s.igwClient, s.igwChannel); err != nil {
|
||||
log.Printf("lk-gateway: авто-обновление справочника: %v", err)
|
||||
} else {
|
||||
log.Printf("lk-gateway: авто-обновление справочника: запрос отправлен (#M2MHR id=%s)", id)
|
||||
}
|
||||
cancel()
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerSetCallback — служебный POST /admin/api/callback-url для
|
||||
// эмулятора ЛК, чтобы сообщить gateway свой URL. Если URL уже сохранён
|
||||
// в runtime-конфиге (пользователь явно настроил его через UI), запрос
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Заявка <code>{{slice .Claim.ID 0 8}}</code> · <span class="badge {{.Claim.Status}}">{{ruState .Claim.Status}}</span>{{if and (or .Fail (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/resend" style="margin:0 0 0 auto" onsubmit="this.querySelector('button').disabled=true;this.querySelector('button').textContent='Отправляем…';"><button type="submit" class="btn">Отправить повторно</button></form>{{end}}{{if and (or (eq .Claim.Status "draft") (eq .Claim.Status "validated")) (can "operate")}}<form method="post" action="/admin/claims/{{.Claim.ID}}/delete-draft" style="margin:0{{if not (or .Fail (eq .Claim.Status "validated"))}} 0 0 auto{{end}}" onsubmit="return confirm('Удалить заявку? Она ещё не уходила в НРД. Если по ней бумаги заблокированы в QUIK МНП, блокировка будет снята.');"><button type="submit" class="btn" style="background:var(--card);color:var(--err,#c0392b);border:1px solid var(--err,#c0392b)">{{if eq .Claim.Status "draft"}}Удалить черновик{{else}}Удалить{{end}}</button></form>{{end}}</h2>
|
||||
{{with .Fail}}<div class="card" style="background:var(--err-weak,#fbe9e7);margin:8px 0">
|
||||
<b>Перевод не доставлен в НРД</b> — {{sendFailText .}}.<br>
|
||||
<span class="muted">Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. Когда НРД снова будет доступен, нажмите «Отправить повторно» — уйдёт тот же запрос с тем же GUID.</span>
|
||||
<span class="muted">Попытка {{.At.Format "02.01.2006 15:04:05"}}{{if .PackageID}}, пакет ИШ {{.PackageID}}{{end}}{{if gt .Attempts 1}}, попыток: {{.Attempts}}{{end}}. Отправка в DEPO не отмечена, блокировка в QUIK МНП сохраняется. {{if .GaveUp}}Сутки прошли — автоповтор остановлен: отправьте вручную или удалите заявку.{{else}}Ничего нажимать не нужно: как только НРД снова ответит, bj сам отправит тот же запрос с тем же GUID (повтор — в течение суток). Кнопка «Отправить повторно» — если нужно попробовать прямо сейчас.{{end}}</span>
|
||||
</div>{{end}}
|
||||
{{$l := liveOut .Claim}}<div class="live live-big live-{{$l.Kind}}" data-live="out:{{.Claim.ID}}" data-sig="{{$l.Sig}}"{{if $l.Active}} data-active="1"{{else}} title="Завершено: состояние больше не проверяется"{{end}}>{{$l.Text}}</div>
|
||||
<table>
|
||||
|
||||
@@ -1,4 +1,58 @@
|
||||
{{define "content"}}
|
||||
{{if .NSDLink.Configured}}<div class="card" id="nsd">
|
||||
<h2 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Связь с НРД
|
||||
<span class="badge {{if eq .NSDLink.State "ok"}}confirmed{{else if eq .NSDLink.State "err"}}rejected{{else}}awaiting_decision{{end}}">{{if eq .NSDLink.State "ok"}}работает{{else if eq .NSDLink.State "err"}}сбой{{else if eq .NSDLink.State "warn"}}с перебоями{{else}}нет данных{{end}}</span>
|
||||
{{if can "operate"}}<form method="post" action="/admin/status/nsd-probe" style="margin:0 0 0 auto"><button class="btn" type="submit">Проверить связь сейчас</button></form>{{end}}
|
||||
</h2>
|
||||
{{if .Probe}}<p class="muted">Запрос проверки отправлен — ответ НРД обычно приходит за 20–60 с. Страница обновится сама.</p>{{end}}
|
||||
<p class="muted" style="margin-top:0">Зелёный — только при свежем подтверждении от НРД, а не при отсутствии ошибок. {{if .NSDLink.Ago}}Последний ответ НРД — {{.NSDLink.Ago}}.{{end}}</p>
|
||||
<table>
|
||||
<thead><tr><th></th><th>Участок</th><th>Состояние</th><th>Чем проверяем</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .NSDLink.Segs}}<tr>
|
||||
<td><span class="nsd-dot {{.State}}"></span></td>
|
||||
<td><strong>{{.Title}}</strong></td>
|
||||
<td>{{.Text}}{{with .Detail}}<br><code>{{.}}</code>{{end}}</td>
|
||||
<td class="muted" style="font-size:12.5px">{{if eq .Key "igw"}}bj обращается к шлюзу каждые 30 с{{else if eq .Key "nsd"}}журнал шлюза: ответы НРД на опросы входящих{{else if eq .Key "m2m"}}раз в 10 мин запрос справочника участников M2M и ответ на него — весь путь до сервиса M2M и обратно{{else}}подтверждение доставки каждого отправленного пакета{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="margin:18px 0 6px;font-size:14px">За последние сутки <span class="muted" style="font-weight:400;font-size:12px">с {{.StripFrom}}, отрезок — 10 минут</span></h3>
|
||||
<div class="nsd-strip">
|
||||
{{range .Strip}}<div class="nsd-row"><span class="nsd-t">{{.Title}}</span><span class="nsd-cells">{{range .Cells}}<i class="{{.State}}" title="{{.Title}}"></i>{{end}}</span><span class="nsd-b muted">{{.Bad}}</span></div>{{end}}
|
||||
<div class="nsd-legend muted"><i class="ok"></i>работает <i class="warn"></i>перебои <i class="err"></i>сбой <i class="none"></i>нет данных</div>
|
||||
</div>
|
||||
|
||||
{{if .Fails}}<h3 style="margin:18px 0 6px;font-size:14px">Не доставлены в НРД</h3>
|
||||
<table>
|
||||
<thead><tr><th>Перевод</th><th>Причина</th><th>Первая попытка</th><th>Попыток</th><th></th></tr></thead>
|
||||
<tbody>{{range .Fails}}<tr>
|
||||
<td><a href="/admin/claims/{{.DealID}}"><code>{{slice .GUID 0 8}}</code></a></td>
|
||||
<td>{{.Text}}</td>
|
||||
<td>{{.FirstAt.Format "02.01 15:04"}}</td>
|
||||
<td>{{.Attempts}}</td>
|
||||
<td>{{if .GaveUp}}<span style="color:var(--err)">автоповтор остановлен (прошли сутки)</span>{{else}}<span class="muted">отправится сам, когда НРД ответит</span>{{end}}{{if can "operate"}}<form method="post" action="/admin/claims/{{.DealID}}/resend" style="margin:4px 0 0"><button class="btn" type="submit" style="padding:2px 10px;font-size:12px">Отправить сейчас</button></form>{{end}}</td>
|
||||
</tr>{{end}}</tbody>
|
||||
</table>{{end}}
|
||||
</div>
|
||||
<style>
|
||||
.nsd-dot { width:10px; height:10px; border-radius:50%; display:inline-block; background:var(--muted); }
|
||||
.nsd-dot.ok { background:var(--ok); } .nsd-dot.warn { background:var(--warn); } .nsd-dot.err { background:var(--err); }
|
||||
.nsd-strip { display:flex; flex-direction:column; gap:6px; }
|
||||
.nsd-row { display:grid; grid-template-columns:80px 1fr 130px; align-items:center; gap:10px; }
|
||||
.nsd-t { font-size:13px; font-weight:600; }
|
||||
.nsd-b { font-size:12px; text-align:right; }
|
||||
.nsd-cells { display:grid; grid-template-columns:repeat(144, 1fr); gap:1px; height:18px; }
|
||||
.nsd-cells i, .nsd-legend i { display:block; border-radius:2px; background:var(--border); }
|
||||
.nsd-cells i.ok, .nsd-legend i.ok { background:var(--ok); } .nsd-cells i.warn, .nsd-legend i.warn { background:var(--warn); }
|
||||
.nsd-cells i.err, .nsd-legend i.err { background:var(--err); } .nsd-cells i.unknown { background:var(--muted); opacity:.35; }
|
||||
.nsd-legend { display:flex; align-items:center; gap:6px; font-size:12px; margin-top:4px; }
|
||||
.nsd-legend i { width:10px; height:10px; display:inline-block; margin-left:8px; }
|
||||
@media (max-width:700px){ .nsd-row { grid-template-columns:1fr; } .nsd-b { text-align:left; } }
|
||||
</style>
|
||||
<script>setTimeout(function(){ if (!document.querySelector('main form input:focus')) location.reload(); }, {{if .Probe}}20000{{else}}60000{{end}});</script>
|
||||
{{end}}
|
||||
<div class="card">
|
||||
<h2>Статус системы — детально</h2>
|
||||
<table>
|
||||
|
||||
@@ -100,6 +100,15 @@ body {
|
||||
.theme-toggle svg { width:16px; height:16px; }
|
||||
.theme-toggle:hover { background:var(--border); color:var(--text); }
|
||||
.topbar .clock { font-size:11px; color:var(--muted); font-family:var(--mono); font-variant-numeric:tabular-nums; letter-spacing:-0.02em; }
|
||||
.nsd-chain { text-decoration:none; gap:6px; }
|
||||
.nsd-chain .seg { display:inline-flex; align-items:center; gap:4px; }
|
||||
.nsd-chain .seg i { width:8px; height:8px; border-radius:50%; background:var(--muted); display:inline-block; }
|
||||
.nsd-chain .seg.ok i { background:var(--ok); } .nsd-chain .seg.warn i { background:var(--warn); } .nsd-chain .seg.err i { background:var(--err); }
|
||||
.nsd-chain .seg.err { color:var(--err); } .nsd-chain .sep { color:var(--muted); font-weight:400; }
|
||||
.nsd-chain .ago { font-weight:400; color:var(--muted); }
|
||||
.nsd-chain.err { background:var(--err-weak); border-color:var(--err); }
|
||||
.nsd-chain.warn { background:var(--warn-weak); }
|
||||
@media (max-width:1100px){ .nsd-chain .seg > span, .nsd-chain .ago, .nsd-chain .sep { display:none } }
|
||||
.sys-block { display:inline-flex; flex-direction:column; align-items:center; gap:2px; line-height:1.1; }
|
||||
.sys-block .sys-status { padding:3px 9px 3px 7px; font-size:11.5px; }
|
||||
.user-dd .ddbtn.user-chip { display:inline-flex; flex-direction:column; align-items:flex-start; padding:4px 10px; border-radius:8px; margin:0; max-width:220px; }
|
||||
@@ -349,9 +358,7 @@ label { font-size:13px; font-weight:500; }
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<span class="sys-block">
|
||||
{{if and .IGWDown (not .IsMockMode)}}<span class="sys-status err" title="{{.IGWDetail}}" style="background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)">
|
||||
<span class="pulse"></span>ИШ НРД недоступен · {{.IGWDown}}</span>{{else if and .NSDDown (not .IsMockMode)}}<span class="sys-status err" title="{{.IGWDetail}}" style="background:var(--err-weak,#fbe9e7);color:var(--err,#b3261e)">
|
||||
<span class="pulse"></span>НРД не отвечает</span>{{else}}<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
|
||||
{{if and .NSDLink.Configured (not .IsMockMode)}}<a id="nsd-chain" href="/admin/status#nsd" class="sys-status nsd-chain {{.NSDLink.State}}" title="{{nsdSegTip .NSDLink}}">{{range $i, $s := .NSDLink.Segs}}{{if $i}}<span class="sep" aria-hidden="true">›</span>{{end}}<span class="seg {{$s.State}}"><i></i><span>{{$s.Title}}</span></span>{{end}}<span class="ago">{{if .NSDLink.Ago}}ответ НРД {{.NSDLink.Ago}}{{else}}ответа НРД ещё не было{{end}}</span></a>{{else}}<span class="sys-status {{if .IsMockMode}}warn{{end}}" title="Состояние обмена с НРД">
|
||||
<span class="pulse"></span>{{if .IsMockMode}}{{if devOn}}Эмуляция{{else}}Нет связи с НРД{{end}}{{else}}НРД на связи{{end}}</span>{{end}}
|
||||
<span class="clock">{{.Now}}</span>
|
||||
</span>
|
||||
@@ -555,6 +562,31 @@ label { font-size:13px; font-weight:500; }
|
||||
}
|
||||
poll();
|
||||
setInterval(poll, 12000); // каждые 12 сек
|
||||
// Цепочка связи с НРД в шапке: обновляется каждые 30 с, чтобы открытая страница не показывала старое.
|
||||
(function(){
|
||||
var el = document.getElementById('nsd-chain');
|
||||
if (!el) return;
|
||||
function esc(t){ return String(t).replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); }
|
||||
function upd(){
|
||||
fetch('/admin/nsd-health.json', {cache:'no-store'}).then(function(r){ return r.json(); }).then(function(h){
|
||||
if (!h.configured) return;
|
||||
var html = '', tip = '';
|
||||
(h.segs||[]).forEach(function(s, i){
|
||||
if (i) html += '<span class="sep" aria-hidden="true">›</span>';
|
||||
html += '<span class="seg ' + esc(s.state) + '"><i></i><span>' + esc(s.title) + '</span></span>';
|
||||
tip += s.title + ': ' + s.text + (s.detail ? ' (' + s.detail + ')' : '') + '\n';
|
||||
});
|
||||
html += '<span class="ago">' + (h.ago ? 'ответ НРД ' + esc(h.ago) : 'ответа НРД ещё не было') + '</span>';
|
||||
el.innerHTML = html;
|
||||
el.className = 'sys-status nsd-chain ' + h.state;
|
||||
el.title = tip + 'Подробно — «Статус». Обновляется каждые 30 с.';
|
||||
}).catch(function(){
|
||||
el.className = 'sys-status nsd-chain warn';
|
||||
el.title = 'Нет связи с bj — состояние НРД не обновляется';
|
||||
});
|
||||
}
|
||||
setInterval(upd, 30000);
|
||||
})();
|
||||
// Онлайн-статус заявок в работе: строки с data-live и data-active опрашиваются, пока ждут.
|
||||
// Карточка заявки (live-big с data-sig): изменилось что-то кроме строки статуса — страница
|
||||
// перерисовывается сама; если оператор уже начал заполнять форму — только предложение обновить.
|
||||
|
||||
Reference in New Issue
Block a user