From d23ddf6e0c9d0b8c832963b91a960f0c425f0860 Mon Sep 17 00:00:00 2001 From: zuevav Date: Tue, 6 Oct 2026 22:44:35 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BF=D0=BE=D0=B2=D0=B5=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F:=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=90=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D1=80=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=C2=BB,=20=D0=B1=D0=BB=D0=BE=D0=BA=20=C2=AB=D0=9A=D0=BE?= =?UTF-8?q?=D0=BC=D1=83=20=D0=BF=D0=B8=D1=81=D0=B0=D1=82=D1=8C=C2=BB=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82;=20=D0=BA=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D0=B8:=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B0=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Администратору (адрес из «Почты») — сбои (ИШ, связь с НРД, службы bj, DEPO) и обновления (новый выпуск bj, ИШ, лицензия, сертификаты); отделам — переводы и поручения. Существующие установки: администратор получает всё, что получал. Новые оповещения: «Доступно обновление bj» (раз на выпуск), «Служба … не работает / снова работает» (модули и служба ключей Валидаты). Резервные копии: \\сервер\папка или сервер + общая папка по полям → //сервер/папка; понятная причина отказа доступа. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi --- deploy/linux/sbin/bj-backup | 20 ++- deploy/linux/sbin/bj-backup-admin | 33 ++++- internal/lkgateway/admin.go | 1 + internal/lkgateway/alerts.go | 24 ++- internal/lkgateway/backup.go | 58 +++++++- internal/lkgateway/firstrun.go | 4 +- internal/lkgateway/incoming_notify.go | 12 +- internal/lkgateway/notifyadmin_test.go | 138 ++++++++++++++++++ internal/lkgateway/runtimeconfig.go | 84 +++++++++-- internal/lkgateway/server.go | 5 +- internal/lkgateway/setup.go | 58 ++++++-- internal/lkgateway/unitswatch.go | 126 ++++++++++++++++ internal/lkgateway/updater.go | 19 +++ .../lkgateway/web/templates/admin_wizard.html | 4 +- .../lkgateway/web/templates/part_alerts.html | 19 +-- .../lkgateway/web/templates/part_backup.html | 5 +- 16 files changed, 547 insertions(+), 63 deletions(-) create mode 100644 internal/lkgateway/notifyadmin_test.go create mode 100644 internal/lkgateway/unitswatch.go diff --git a/deploy/linux/sbin/bj-backup b/deploy/linux/sbin/bj-backup index 6bf7e72..5ccd3fb 100755 --- a/deploy/linux/sbin/bj-backup +++ b/deploy/linux/sbin/bj-backup @@ -9,7 +9,25 @@ CRED=/etc/bj/backup-smb.cred STATUS=/var/lib/bj/backup-status.json [ -r "$CONF" ] && . "$CONF" SHARE=${SHARE:-} -DIR=${DIR:-bj-backup} +DIR=${DIR:-} +# smbloc — адрес хранилища, как его вводят (\\nas\backup\bj как в проводнике, //nas/backup, smb://…, сервер +# в «Хранилище» + общая папка в «Папке») → две строки: общая папка для smbclient (//сервер/папка) и путь внутри неё +smbloc(){ python3 - "$1" "$2" <<'PY' +import re,sys +def parts(s): + s=s.strip().replace('\\','/') + m=re.match(r'(?i)smb://',s) + if m: s=s[m.end():] + return [p.strip() for p in s.split('/') if p.strip()] +sp,dp=parts(sys.argv[1]),parts(sys.argv[2]) +if not sp: print(''); print('/'.join(dp)); sys.exit() +if len(sp)==1: + if not dp: print('//'+sp[0]); print(''); sys.exit() + sp,dp=[sp[0],dp[0]],dp[1:] +print('//'+sp[0]+'/'+sp[1]); print('/'.join(sp[2:]+dp)) +PY +} +mapfile -t _loc < <(smbloc "$SHARE" "$DIR"); SHARE=${_loc[0]:-}; DIR=${_loc[1]:-bj-backup} KEEP_DAYS=${KEEP_DAYS:-14} TS=$(date +%Y-%m-%d_%H%M) W=$(mktemp -d /var/tmp/bj-backup.XXXXXX) diff --git a/deploy/linux/sbin/bj-backup-admin b/deploy/linux/sbin/bj-backup-admin index 9095c02..09876d4 100755 --- a/deploy/linux/sbin/bj-backup-admin +++ b/deploy/linux/sbin/bj-backup-admin @@ -19,8 +19,25 @@ err() { echo "{\"ok\":false,\"error\":$(q "$1")}"; exit 0; } [ -r "$CONF" ] && . "$CONF" # хранилище из запроса (восстановление на новом сервере, проверка до сохранения) или из настроек SHARE_IN=$(j share); DIR_IN=$(j dir); USER_IN=$(j user); PASS_IN=$(j password) -SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-bj-backup}} -case "$SHARE" in //*) ;; \\\\*) SHARE=$(printf '%s' "$SHARE" | tr '\\' '/') ;; ?*) SHARE="//$SHARE" ;; esac +SHARE=${SHARE_IN:-${SHARE:-}}; DIR=${DIR_IN:-${DIR:-}} +# smbloc — адрес хранилища, как его вводят (\\nas\backup\bj как в проводнике, //nas/backup, smb://…, сервер +# в «Хранилище» + общая папка в «Папке») → две строки: общая папка для smbclient (//сервер/папка) и путь внутри неё +smbloc(){ python3 - "$1" "$2" <<'PY' +import re,sys +def parts(s): + s=s.strip().replace('\\','/') + m=re.match(r'(?i)smb://',s) + if m: s=s[m.end():] + return [p.strip() for p in s.split('/') if p.strip()] +sp,dp=parts(sys.argv[1]),parts(sys.argv[2]) +if not sp: print(''); print('/'.join(dp)); sys.exit() +if len(sp)==1: + if not dp: print('//'+sp[0]); print(''); sys.exit() + sp,dp=[sp[0],dp[0]],dp[1:] +print('//'+sp[0]+'/'+sp[1]); print('/'.join(sp[2:]+dp)) +PY +} +mapfile -t _loc < <(smbloc "$SHARE" "$DIR"); SHARE=${_loc[0]:-}; DIR=${_loc[1]:-bj-backup} TMPCRED="" credfile() { if [ -n "$USER_IN" ] && [ -n "$PASS_IN" ]; then @@ -72,7 +89,17 @@ case "$cmd" in f=/tmp/bj-backup-test.$$; echo test > "$f"; top=${DIR%%/*} smbx "$c" "mkdir \"$top\"; mkdir \"$DIR\"" >/dev/null out=$(smbx "$c" "cd \"$DIR\"; put $f bj-write-test.txt; del bj-write-test.txt"); rc=$?; rm -f "$f" - if [ $rc -ne 0 ] || printf '%s' "$out" | grep -q "NT_STATUS"; then err "нет доступа на запись в $SHARE/$DIR: $(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1)"; fi + if [ $rc -ne 0 ] || printf '%s' "$out" | grep -q "NT_STATUS"; then + why=$(printf '%s' "$out" | grep -o 'NT_STATUS[A-Z_]*' | head -1) + case "$why" in + NT_STATUS_BAD_NETWORK_NAME) why="на сервере нет общей папки «${SHARE##*/}» ($why)" ;; + NT_STATUS_LOGON_FAILURE) why="неверный логин или пароль ($why)" ;; + NT_STATUS_ACCESS_DENIED) why="у логина нет прав на запись ($why)" ;; + NT_STATUS_HOST_UNREACHABLE|NT_STATUS_IO_TIMEOUT|NT_STATUS_CONNECTION_REFUSED) why="сервер хранилища недоступен ($why)" ;; + "") why=$(printf '%s' "$out" | grep -v '^\s*$' | tail -1) ;; + esac + err "нет доступа на запись в $SHARE/$DIR: ${why:-smbclient завершился с кодом $rc}" + fi ok "доступ на запись к $SHARE/$DIR есть" ;; run) systemctl start --no-block bj-backup.service || err "не удалось запустить bj-backup.service" diff --git a/internal/lkgateway/admin.go b/internal/lkgateway/admin.go index 5b75999..31d39da 100644 --- a/internal/lkgateway/admin.go +++ b/internal/lkgateway/admin.go @@ -59,6 +59,7 @@ var templateFuncs = template.FuncMap{ "lkPortfolioStates": lkPortfolioStates, "backupStatus": backupStatus, "backupWhen": backupWhen, + "smbWin": smbWin, "depoAccPrefix": depoAccPrefix, "certDaysLeft": certDaysLeft, "currentContour": func() string { return contourOf(currentNSDProfile()) }, diff --git a/internal/lkgateway/alerts.go b/internal/lkgateway/alerts.go index 1050b1d..39646c6 100644 --- a/internal/lkgateway/alerts.go +++ b/internal/lkgateway/alerts.go @@ -28,9 +28,9 @@ func dispatchAlert(rc *RuntimeConfig, title, body string) { cfg := rc.Snapshot() a := cfg.Alerts if a.SMTP.Enabled { - // адрес алертов + группы по маршруту типа (сроки поручений / QUIK / системные) + // администратор системы и отделы — по маршруту типа (сроки поручений / QUIK / сбои / обновления / прочие) s := a.SMTP - s.To = mergeEmails(a.SMTP.To, cfg.Incoming.Recipients(alertKind(title))) + s.To = notifyRecipients(cfg, alertKind(title)) if strings.TrimSpace(s.To) != "" { if err := sendSMTP(s, "[bj] "+title, body); err != nil { log.Printf("lk-gateway: алерт SMTP не доставлен: %v", err) @@ -298,13 +298,29 @@ func readFull(conn net.Conn, buf []byte) (int, error) { return total, nil } -// alertKind — тип оповещения по заголовку (для маршрутизации по группам адресов). +// alertKind — тип оповещения по заголовку (маршрут «какой тип кому» — в настройках уведомлений). Сбои и +// обновления — администратору системы: падение ИШ или службы отделам ни к чему, им — переводы и поручения. func alertKind(title string) string { + has := func(prefixes ...string) bool { + for _, p := range prefixes { + if strings.HasPrefix(title, p) { + return true + } + } + return false + } switch { - case strings.HasPrefix(title, "НРД: депо-поручение") || strings.HasPrefix(title, "НРД: истёк срок"): + case has("НРД: депо-поручение", "НРД: истёк срок"): return "orders" case strings.Contains(title, "QUIK"): return "quik" + case has("ИШ НРД", "Перезапуск ИШ НРД", "НРД не отвечает", "НРД всё ещё не отвечает", "НРД снова на связи", + "Проверка связи с НРД", "Сбой компонента", "Компонент не отвечает", "Служба ", "DEPO: bj не может", + "DEPO: вход логином", "DEPO: обработчик", "Резервная копия"): + return "infra" + case has("Доступно обновление", "ИШ обновлён", "ИШ не обновлён", "Лицензия", "Поддержка bj", "Модуль «", + "Сертификат", "Версия внешней системы"): + return "updates" } return "system" } diff --git a/internal/lkgateway/backup.go b/internal/lkgateway/backup.go index bfe2d1e..077b175 100644 --- a/internal/lkgateway/backup.go +++ b/internal/lkgateway/backup.go @@ -46,6 +46,49 @@ func (b BackupSettings) keep() int { func (b BackupSettings) dir() string { return pickStr(strings.TrimSpace(b.Dir), "bj-backup") } +// smbLocation — адрес хранилища, как его вводят люди (\\nas\backup\bj как в проводнике, //nas/backup, smb://nas/backup; +// или сервер в поле «Хранилище», а общая папка — в поле «Папка»), → общая папка для smbclient (//сервер/папка) и +// путь внутри неё. Раньше «\\10.10.10.11\» + «backup_test» превращалось в //10.10.10.11//backup_test — без общей +// папки, и доступ не проверялся. Только сервер без общей папки — share «//сервер» (форма скажет, чего не хватает). +func smbLocation(share, dir string) (string, string) { + parts := func(s string) []string { + s = strings.ReplaceAll(strings.TrimSpace(s), `\`, "/") + if i := strings.Index(s, "://"); i > 0 && strings.EqualFold(s[:i], "smb") { + s = s[i+3:] + } + var out []string + for _, p := range strings.Split(s, "/") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out + } + sp, dp := parts(share), parts(dir) + if len(sp) == 0 { + return "", strings.Join(dp, "/") + } + if len(sp) == 1 { // только сервер: общая папка — начало «Папки» + if len(dp) == 0 { + return "//" + sp[0], "" + } + sp, dp = []string{sp[0], dp[0]}, dp[1:] + } + sub := append(append([]string{}, sp[2:]...), dp...) + return "//" + sp[0] + "/" + sp[1], strings.Join(sub, "/") +} + +// smbWin — адрес хранилища так, как его видят в проводнике Windows (//nas/backup → \\nas\backup). +func smbWin(share string) string { + if !strings.HasPrefix(share, "//") { + return share + } + return strings.ReplaceAll(share, "/", `\`) +} + +// smbShareOK — в адресе есть и сервер, и общая папка. +func smbShareOK(share string) bool { return strings.Count(strings.TrimPrefix(share, "//"), "/") == 1 } + // BackupStatus — итог последней копии (пишет root-скрипт). type BackupStatus struct { At string `json:"at"` @@ -85,6 +128,12 @@ func backupParams(r *http.Request) map[string]string { p[k] = v } } + if p["share"] != "" { // адрес как в проводнике → общая папка и путь внутри неё + p["share"], p["dir"] = smbLocation(p["share"], p["dir"]) + if p["dir"] == "" { + delete(p, "dir") + } + } return p } @@ -110,14 +159,17 @@ func registerBackup(mux *http.ServeMux, rc *RuntimeConfig) { } b := rc.Snapshot().Backup b.Enabled = r.FormValue("enabled") != "" - b.Share = strings.TrimSpace(r.FormValue("share")) - b.Dir = strings.TrimSpace(r.FormValue("dir")) + b.Share, b.Dir = smbLocation(r.FormValue("share"), r.FormValue("dir")) b.User = strings.TrimSpace(r.FormValue("user")) b.EveryHours, _ = strconv.Atoi(r.FormValue("every_hours")) b.KeepDays, _ = strconv.Atoi(r.FormValue("keep_days")) pass := r.FormValue("password") if b.Share == "" { - setupFlash(w, r, "Резервное копирование: укажите хранилище, например //nas/backup") + setupFlash(w, r, `Резервное копирование: укажите сетевую папку, как в проводнике, например \\nas\backup`) + return + } + if !smbShareOK(b.Share) { + setupFlash(w, r, `Резервное копирование: укажите не только сервер, но и общую папку на нём — например \\`+strings.TrimPrefix(b.Share, "//")+`\backup`) return } ctx, cancel := context.WithTimeout(r.Context(), time.Minute) diff --git a/internal/lkgateway/firstrun.go b/internal/lkgateway/firstrun.go index ab37367..1940644 100644 --- a/internal/lkgateway/firstrun.go +++ b/internal/lkgateway/firstrun.go @@ -222,8 +222,8 @@ code{font-size:12px}
Переустановка сервера? Восстановить из резервной копии

Вместо создания администратора можно вернуть сервер из резервной копии: настройки, пользователи, лицензия, заявки, база и папка шлюза НРД. Нужен одноразовый код (поле вверху) и доступ к хранилищу копий.

- - + + diff --git a/internal/lkgateway/incoming_notify.go b/internal/lkgateway/incoming_notify.go index dc1e263..5843a90 100644 --- a/internal/lkgateway/incoming_notify.go +++ b/internal/lkgateway/incoming_notify.go @@ -23,7 +23,7 @@ func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) { } cfg := rc.Snapshot() inc := cfg.Incoming - if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" { + if !inc.Enabled || strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" { return } smtp := cfg.Alerts.SMTP @@ -34,13 +34,13 @@ func notifyDepositoryIncoming(rc *RuntimeConfig, it *IncomingTransfer) { subject := "MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт" body := buildIncomingEmailBody(it) s := smtp - s.To = inc.Recipients("incoming") // получатели по маршруту «входящие переводы» + s.To = notifyRecipients(cfg, "incoming") // получатели по маршруту «входящие переводы» if err := sendSMTP(s, subject, body); err != nil { log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s не доставлено: %v", it.GUID, err) return } log.Printf("lk-gateway: уведомление депозитарию о входящем GUID=%s отправлено (%d бумаг) на %s", - it.GUID, len(it.Securities), inc.Recipients("incoming")) + it.GUID, len(it.Securities), notifyRecipients(cfg, "incoming")) } // notifyDepositoryAccepted отправляет письмо депозитарию о том, что входящий @@ -51,7 +51,7 @@ func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed } cfg := rc.Snapshot() inc := cfg.Incoming - if !inc.Enabled || strings.TrimSpace(inc.Recipients("incoming")) == "" { + if !inc.Enabled || strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" { return } smtp := cfg.Alerts.SMTP @@ -94,12 +94,12 @@ func notifyDepositoryAccepted(rc *RuntimeConfig, it *IncomingTransfer, confirmed b.WriteString("\nЭто уведомление сформировано автоматически системой Bridge-and-Join-s (bj).\n") m := smtp - m.To = inc.Recipients("incoming") + m.To = notifyRecipients(cfg, "incoming") if err := sendSMTP(m, subject, b.String()); err != nil { log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s не доставлено: %v", it.GUID, err) return } - log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, inc.Recipients("incoming")) + log.Printf("lk-gateway: письмо депозитарию о приёме GUID=%s отправлено на %s", it.GUID, notifyRecipients(cfg, "incoming")) } // buildIncomingEmailBody собирает текст письма депозитарию. diff --git a/internal/lkgateway/notifyadmin_test.go b/internal/lkgateway/notifyadmin_test.go new file mode 100644 index 0000000..ed0dddf --- /dev/null +++ b/internal/lkgateway/notifyadmin_test.go @@ -0,0 +1,138 @@ +package lkgateway + +import ( + "path/filepath" + "strings" + "testing" +) + +// Сбои и обновления — отдельные типы (по умолчанию — администратору системы), деловые — отделам. +func TestAlertKindAdmin(t *testing.T) { + cases := map[string]string{ + "ИШ НРД недоступен": "infra", + "Перезапуск ИШ НРД не помог (попытка 1 из 3)": "infra", + "НРД не отвечает шлюзу": "infra", + "Компонент не отвечает: poll-incoming": "infra", + "Служба «Подпись» не работает": "infra", + "DEPO: вход логином bj_user не удался": "infra", + "Доступно обновление bj 2026.10.06.24": "updates", + "ИШ обновлён до 107.0.930": "updates", + "Лицензия: сервер лицензий отказал в продлении": "updates", + "Поддержка bj заканчивается через 10 дней": "updates", + "Сертификат ключа организации истекает через 5": "updates", + "НРД: депо-поручение не исполнено в срок 5 минут": "orders", + "QUIK МНП: не удалось зачислить бумаги": "quik", + "Перевод не доставлен в НРД": "system", + "DEPO: джоба не обработала перевод ab12": "system", + } + for title, want := range cases { + if got := alertKind(title); got != want { + t.Errorf("%q: %s, ожидалось %s", title, got, want) + } + } +} + +// Существующая установка: администратор (адрес из «Почты») получает всё, что получал раньше; выключенные +// уведомления о переводах — пустой маршрут. Новая установка — маршруты по умолчанию. +func TestNotifyRoutesMigration(t *testing.T) { + var old Settings + old.Alerts.SMTP.To = "it@example.ru" + old.Incoming.Emails = "depo@example.ru" + migrateNotifyRoutes(&old) + r := old.Incoming.Routes + for _, k := range []string{"orders", "quik", "infra", "updates", "system"} { + if !old.Incoming.RouteHas(k, "admin") { + t.Errorf("%s: администратор должен остаться получателем (%q)", k, r[k]) + } + } + if r["incoming"] != "" || old.Incoming.RouteHas("incoming", "depo") || !old.Incoming.Enabled || old.Incoming.RoutesV != 2 { + t.Errorf("выключенные уведомления о переводах: %+v", old.Incoming) + } + if !old.Incoming.RouteHas("infra", "back") || !old.Incoming.RouteHas("orders", "depo") { + t.Errorf("сбои — тем же отделам, что «прочие» раньше: %v", r) + } + if got := notifyRecipients(old, "infra"); got != "it@example.ru" { + t.Errorf("сбои: %q", got) + } + migrateNotifyRoutes(&old) // повторно — без изменений + if old.Incoming.Routes["orders"] != r["orders"] || strings.Count(old.Incoming.Routes["orders"], "admin") != 1 { + t.Errorf("повторная миграция: %v", old.Incoming.Routes) + } + + var fresh Settings + migrateNotifyRoutes(&fresh) + fresh.Alerts.SMTP.To = "it@example.ru" + fresh.Incoming.Emails, fresh.Incoming.EmailsBack = "depo@example.ru", "back@example.ru" + if fresh.Incoming.Routes != nil || !fresh.Incoming.Enabled { + t.Fatalf("новая установка: %+v", fresh.Incoming) + } + want := map[string]string{ + "incoming": "depo@example.ru, back@example.ru", + "orders": "depo@example.ru, back@example.ru", + "infra": "it@example.ru", + "updates": "it@example.ru", + "system": "it@example.ru, back@example.ru", + } + for k, w := range want { + if got := notifyRecipients(fresh, k); got != w { + t.Errorf("%s: %q, ожидалось %q", k, got, w) + } + } +} + +// Служба пишет администратору, только пролежав две проверки подряд, один раз; поднялась — второе письмо. +func TestUnitsWatchTick(t *testing.T) { + rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) + if err != nil { + t.Fatal(err) + } + unitsWatch.Lock() + unitsWatch.down, unitsWatch.alerted = map[string]int{}, map[string]bool{} + unitsWatch.Unlock() + down := []watchedUnit{{Key: "bj-npr", Title: "Риск-монитор", Unit: "bj-npr", State: "failed/failed"}} + up := []watchedUnit{{Key: "bj-npr", Title: "Риск-монитор", Unit: "bj-npr", OK: true}} + count := func(sub string) int { + n := 0 + for _, it := range rc.Snapshot().News.Items { + if strings.Contains(it.Title, sub) { + n++ + } + } + return n + } + unitsWatchTick(rc, down) + if count("не работает") != 0 { + t.Fatal("после одной проверки письма быть не должно") + } + unitsWatchTick(rc, down) + unitsWatchTick(rc, down) + if count("не работает") != 1 { + t.Fatalf("оповещений о сбое: %d", count("не работает")) + } + unitsWatchTick(rc, up) + if count("снова работает") != 1 { + t.Fatalf("оповещений о восстановлении: %d", count("снова работает")) + } +} + +// Адрес хранилища копий — как его вводят люди: в проводнике \\сервер\папка, или сервер и общая папка по разным полям. +func TestSMBLocation(t *testing.T) { + cases := []struct{ share, dir, wantShare, wantDir string }{ + {`\\10.10.10.11\`, "backup_test", "//10.10.10.11/backup_test", ""}, + {`\\10.10.10.11\backup_test`, "", "//10.10.10.11/backup_test", ""}, + {`\\nas\backup\bj\copies`, "x", "//nas/backup", "bj/copies/x"}, + {"//nas/backup", "bj-backup", "//nas/backup", "bj-backup"}, + {"smb://nas/backup/", "", "//nas/backup", ""}, + {"nas", "", "//nas", ""}, + {"", "bj", "", "bj"}, + } + for _, c := range cases { + s, d := smbLocation(c.share, c.dir) + if s != c.wantShare || d != c.wantDir { + t.Errorf("%q + %q → %q %q, ожидалось %q %q", c.share, c.dir, s, d, c.wantShare, c.wantDir) + } + } + if smbShareOK("//nas") || !smbShareOK("//nas/backup") || smbWin("//nas/backup") != `\\nas\backup` { + t.Error("smbShareOK/smbWin") + } +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 75b5313..27dfd7a 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -110,6 +110,8 @@ type UpdateSettings struct { // сменили, данные прошлой проверки к новому каналу не относятся: предлагать // по ним обновление нельзя (кнопка «Обновить» уходила бы в пустоту). ComponentsChannel string `json:"components_channel,omitempty"` + // Notified — выпуск, о котором уже написали администратору («Доступно обновление bj»): по разу на выпуск. + Notified string `json:"notified,omitempty"` } // NewsSettings — лента новостей (события системы, окна техработ, обновления @@ -358,6 +360,7 @@ func NewRuntimeConfig(path string) (*RuntimeConfig, error) { if err := rc.load(); err != nil && !errors.Is(err, os.ErrNotExist) { return nil, err } + migrateNotifyRoutes(&rc.data) // и на новой установке: версия маршрутов сохранится с первой записью настроек // Гарантируем разумные дефолты. if rc.data.Crypto.Provider == "" { rc.data.Crypto.Provider = "stub" @@ -595,18 +598,25 @@ type IncomingNotifySettings struct { Emails string `json:"emails"` // депозитарий (через запятую) EmailsBack string `json:"emails_back"` // бэк-офис EmailsMiddle string `json:"emails_middle"` // мидл-офис - // Маршрутизация: тип уведомления → группы через запятую (depo,back,middle). - // Типы: incoming (входящие переводы), orders (сроки депо-поручений), quik (QUIK МНП), system (прочие). + // Маршрутизация: тип уведомления → группы через запятую (admin,depo,back,middle). Адрес группы admin + // (администратор системы) — Alerts.SMTP.To. Типы: incoming (входящие переводы), orders (сроки депо-поручений), + // quik (QUIK МНП), infra (сбои шлюза, связи с НРД, служб, DEPO), updates (обновления, лицензия, сертификаты), + // system (прочие). Routes map[string]string `json:"routes"` + // RoutesV — версия маршрутов: 2 — с группой «Администратор системы» (06.10.2026), см. migrateNotifyRoutes. + RoutesV int `json:"routes_v,omitempty"` } -// Группы получателей и типы уведомлений (порядок — для формы). -var notifyGroups = []struct{ Key, Title string }{{"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}} +// Группы получателей и типы уведомлений (порядок — для формы). Администратор системы — тот, кто отвечает за +// сервер и ИТ: ему сбои и обновления, которые отделам (депозитарий, бэк- и мидл-офис) не нужны. +var notifyGroups = []struct{ Key, Title string }{{"admin", "Администратор"}, {"depo", "Депозитарий"}, {"back", "Бэк-офис"}, {"middle", "Мидл-офис"}} var notifyKinds = []struct{ Key, Title string }{ {"incoming", "Входящие переводы (получен / принят)"}, {"orders", "Сроки депо-поручений НРД (MS010 5 мин / 30 дней)"}, {"quik", "QUIK МНП (ошибки блокировки / списания / зачисления)"}, - {"system", "Прочие системные оповещения"}, + {"infra", "Сбои: шлюз НРД, связь с НРД, службы bj, обмен с DEPO"}, + {"updates", "Обновления bj и шлюза, лицензия, сертификаты"}, + {"system", "Прочие оповещения"}, } // visibleNotifyKinds — типы уведомлений этой установки: ошибки QUIK МНП — только при модуле в лицензии. @@ -621,16 +631,18 @@ func visibleNotifyKinds(rc *RuntimeConfig) []struct{ Key, Title string } { return out } -// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои. -var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"} +// defaultNotifyRoutes — маршруты по умолчанию, пока оператор не настроил свои: сбои и обновления — +// администратору, переводы и поручения — отделам. +var defaultNotifyRoutes = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", + "infra": "admin", "updates": "admin", "system": "admin,back"} -// RouteHas — получает ли группа уведомления данного типа. +// RouteHas — получает ли группа уведомления данного типа (тип, которого нет в сохранённых маршрутах, — по умолчанию). func (s IncomingNotifySettings) RouteHas(kind, group string) bool { - r := s.Routes - if r == nil { - r = defaultNotifyRoutes + route, ok := s.Routes[kind] + if !ok { + route = defaultNotifyRoutes[kind] } - for _, g := range strings.Split(r[kind], ",") { + for _, g := range strings.Split(route, ",") { if strings.TrimSpace(g) == group { return true } @@ -651,7 +663,44 @@ func (s IncomingNotifySettings) GroupEmails(group string) string { return "" } -// Recipients — адреса всех групп, на которые направлен тип уведомления (без дублей). +// migrateNotifyRoutes — переход на маршруты с группой «Администратор системы». До неё адрес из раздела «Почта» +// получал все системные оповещения, а сбои и обновления были «прочими»: на существующей установке администратор +// получает всё то же, что и раньше (лишнее снимается в таблице), сбои и обновления идут тем же группам, что +// «прочие». Галочка «уведомления о переводах» заменена строкой таблицы: выключенная — пустой маршрут входящих. +// Новая установка (ни адреса, ни маршрутов) — маршруты по умолчанию. +func migrateNotifyRoutes(s *Settings) { + in := &s.Incoming + if in.RoutesV >= 2 { + return + } + in.RoutesV = 2 + if in.Routes == nil && strings.TrimSpace(s.Alerts.SMTP.To) == "" && !in.Enabled { + in.Enabled = true + return + } + old := in.Routes + if old == nil { + old = map[string]string{"incoming": "depo,back", "orders": "depo,back", "quik": "back,middle", "system": "back"} + } + r := map[string]string{} + for k, v := range old { + r[k] = v + } + r["infra"], r["updates"] = old["system"], old["system"] + if strings.TrimSpace(s.Alerts.SMTP.To) != "" { + for _, k := range []string{"orders", "quik", "infra", "updates", "system"} { + r[k] = strings.Trim(r[k]+",admin", ",") + } + } + if !in.Enabled { + r["incoming"] = "" + } + in.Enabled = true + in.Routes = r +} + +// Recipients — адреса отделов (депозитарий, бэк-, мидл-офис), на которые направлен тип уведомления (без дублей); +// с администратором системы — notifyRecipients. func (s IncomingNotifySettings) Recipients(kind string) string { seen := map[string]bool{} var out []string @@ -671,6 +720,15 @@ func (s IncomingNotifySettings) Recipients(kind string) string { return strings.Join(out, ", ") } +// notifyRecipients — все адреса типа уведомления: администратор системы (адрес из «Почты») и отделы. +func notifyRecipients(s Settings, kind string) string { + admin := "" + if s.Incoming.RouteHas(kind, "admin") { + admin = s.Alerts.SMTP.To + } + return mergeEmails(admin, s.Incoming.Recipients(kind)) +} + func (r *RuntimeConfig) UpdateIncoming(s IncomingNotifySettings) error { r.mu.Lock() r.data.Incoming = s diff --git a/internal/lkgateway/server.go b/internal/lkgateway/server.go index 207f581..cab0ece 100644 --- a/internal/lkgateway/server.go +++ b/internal/lkgateway/server.go @@ -301,8 +301,9 @@ func (s *Server) Run(ctx context.Context) error { // Все фоновые горутины — под supervise: паника ловится recover(), пишется // в лог и в ленту, горутина перезапускается. Один сбой не роняет сервис. go superviseLoop(ctx, "consume-decisions", s.rc, s.consumeDecisions) - go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск - go handbookLoop(ctx, s.rc) // справочник участников M2M с сервера лицензий (для установок без шлюза НРД) + go superviseLoop(ctx, "igw-watch", s.rc, s.igwWatchLoop) // сторож ИШ НРД: оповещения и автоперезапуск + go superviseLoop(ctx, "units-watch", s.rc, s.unitsWatchLoop) // службы модулей и ключей Валидаты: письмо администратору + go handbookLoop(ctx, s.rc) // справочник участников M2M с сервера лицензий (для установок без шлюза НРД) // Поллер входящих от НРД (только в реальном режиме ИШ): забирает // ответы робота/контрагента и применяет их через ApplyDecision. diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index ff2fbe6..d65d8d8 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -1041,7 +1041,7 @@ func (h *setupHandlers) saveAlertsSMTP(w http.ResponseWriter, r *http.Request) { Username: strings.TrimSpace(r.FormValue("username")), Password: pickStr(r.FormValue("password"), a.SMTP.Password), // пароль в страницу не выводится: пусто — прежний From: strings.TrimSpace(r.FormValue("from")), - To: strings.TrimSpace(r.FormValue("to")), + To: a.SMTP.To, // адрес администратора системы — в «Кому писать» (notifyGroupsCard) LastMsg: a.SMTP.LastMsg, LastAt: a.SMTP.LastAt, } @@ -1058,8 +1058,22 @@ func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "method", http.StatusMethodNotAllowed) return } - a := h.rc.Snapshot().Alerts - err := sendSMTP(a.SMTP, "[bj] Проверка уведомлений", "Это проверочное письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.") + cfg := h.rc.Snapshot() + a := cfg.Alerts + // кому: администратор системы, а пока его адреса нет — все адреса отделов из «Кому писать» + test := a.SMTP + if strings.TrimSpace(test.To) == "" { + var all []string + for _, k := range notifyKinds { + all = append(all, cfg.Incoming.Recipients(k.Key)) + } + test.To = mergeEmails(all...) + } + if strings.TrimSpace(test.To) == "" { + setupFlash(w, r, "Проверка SMTP: укажите хотя бы один адрес в блоке «Кому писать» и сохраните") + return + } + err := sendSMTP(test, "[bj] Проверка уведомлений", "Это проверочное письмо от bj-server. Если вы его получили — SMTP-канал оповещений работает.") a.SMTP.LastAt = time.Now() if err != nil { a.SMTP.LastMsg = "ошибка: " + err.Error() @@ -1067,9 +1081,9 @@ func (h *setupHandlers) testAlertsSMTP(w http.ResponseWriter, r *http.Request) { setupFlash(w, r, "Проверка SMTP: "+err.Error()) return } - a.SMTP.LastMsg = "проверочное письмо отправлено" + a.SMTP.LastMsg = "проверочное письмо отправлено на " + test.To _ = h.rc.UpdateAlerts(a) - setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+a.SMTP.To) + setupFlash(w, r, "Проверка SMTP: письмо отправлено на "+test.To) } // saveAlertsZabbix сохраняет настройки Zabbix-хука. @@ -1121,19 +1135,24 @@ func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Reques http.Error(w, "method", http.StatusMethodNotAllowed) return } + // какие уведомления кому — только таблица: прежняя галочка «уведомления о переводах» — строка «Входящие» s := IncomingNotifySettings{ - Enabled: r.FormValue("enabled") == "on", + Enabled: true, Emails: strings.TrimSpace(r.FormValue("emails")), EmailsBack: strings.TrimSpace(r.FormValue("emails_back")), EmailsMiddle: strings.TrimSpace(r.FormValue("emails_middle")), Routes: map[string]string{}, + RoutesV: 2, } - prev := h.rc.Snapshot().Incoming.Routes - if prev == nil { - prev = defaultNotifyRoutes - } + cur := h.rc.Snapshot() for _, k := range notifyKinds { // скрытые в форме типы (модуль не в лицензии) — прежний маршрут - s.Routes[k.Key] = prev[k.Key] + var gs []string + for _, g := range notifyGroups { + if cur.Incoming.RouteHas(k.Key, g.Key) { + gs = append(gs, g.Key) + } + } + s.Routes[k.Key] = strings.Join(gs, ",") } for _, k := range visibleNotifyKinds(h.rc) { var gs []string @@ -1148,6 +1167,15 @@ func (h *setupHandlers) saveIncomingNotify(w http.ResponseWriter, r *http.Reques setupFlash(w, r, "Уведомления: ошибка сохранения: "+err.Error()) return } + // адрес администратора системы хранится в настройках почты (Alerts.SMTP.To) — им же писали всегда + if _, ok := r.Form["emails_admin"]; ok { + a := cur.Alerts + a.SMTP.To = strings.TrimSpace(r.FormValue("emails_admin")) + if err := h.rc.UpdateAlerts(a); err != nil { + setupFlash(w, r, "Уведомления: ошибка сохранения адреса администратора: "+err.Error()) + return + } + } setupFlash(w, r, "Адреса и маршруты уведомлений сохранены") } @@ -1159,8 +1187,8 @@ func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Reques return } cfg := h.rc.Snapshot() - if strings.TrimSpace(cfg.Incoming.Recipients("incoming")) == "" { - setupFlash(w, r, "Проверка: сначала укажите почту депозитария") + if strings.TrimSpace(notifyRecipients(cfg, "incoming")) == "" { + setupFlash(w, r, "Проверка: сначала укажите адреса и отметьте в таблице, кому идут «Входящие переводы»") return } smtp := cfg.Alerts.SMTP @@ -1174,12 +1202,12 @@ func (h *setupHandlers) testIncomingNotify(w http.ResponseWriter, r *http.Reques ReceivedAt: time.Now(), Securities: []IncomingSecurity{{SecurityCode: "RU0009029540", ISIN: "RU0009029540", Quantity: "10"}}, } - smtp.To = cfg.Incoming.Recipients("incoming") + smtp.To = notifyRecipients(cfg, "incoming") if err := sendSMTP(smtp, "[ПРОВЕРКА] MOEX МОСТ (M2M): к вам переведены ценные бумаги — требуется учёт", buildIncomingEmailBody(sample)); err != nil { setupFlash(w, r, "Проверка уведомления депозитарию: "+err.Error()) return } - setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+cfg.Incoming.Recipients("incoming")) + setupFlash(w, r, "Проверка: письмо-уведомление отправлено на "+notifyRecipients(cfg, "incoming")) } // lkPostJSON — POST JSON к API ЛК с Basic; возвращает (код, тело). diff --git a/internal/lkgateway/unitswatch.go b/internal/lkgateway/unitswatch.go new file mode 100644 index 0000000..16d35cb --- /dev/null +++ b/internal/lkgateway/unitswatch.go @@ -0,0 +1,126 @@ +package lkgateway + +// unitswatch.go — сторож служб (06.10.2026): администратор системы узнаёт письмом, что служба модуля +// (bj-crypto, bj-npr, bj-quikexport, bj-lk) или служба ключей Валидаты не работает, — раньше это было видно +// только в карточке «Службы» или по ошибкам раздела. ИШ НРД сторожит igwwatch, сбои внутри bj-server — health. +// Служба должна лежать две проверки подряд: перезапуск при обновлении письма не вызывает. + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const unitsCheckEvery = 2 * time.Minute + +var unitsWatch = struct { + sync.Mutex + down map[string]int // служба → проверок подряд, когда она не работала + alerted map[string]bool // о службе уже написали +}{down: map[string]int{}, alerted: map[string]bool{}} + +// watchedUnit — служба под присмотром и её состояние сейчас. +type watchedUnit struct { + Key, Title, Unit string + OK bool + State string // как она лежит — для письма + Hint string // что из-за этого не работает и как поднять +} + +func (s *Server) unitsWatchLoop(ctx context.Context) { + healthRegister("units-watch", 2*unitsCheckEvery) + t := time.NewTicker(unitsCheckEvery) + defer t.Stop() + for { + unitsWatchTick(s.rc, watchedUnits(s.rc)) + healthBeat("units-watch") + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// watchedUnits — службы модулей этого сервера (кроме самого bj-server) и служба ключей Валидаты. +func watchedUnits(rc *RuntimeConfig) []watchedUnit { + var out []watchedUnit + for _, v := range unitViews(rc) { + if v.Unit == "bj-server" { + continue + } + st := v.State + "/" + v.Sub + if v.Result != "" { + st += ", причина: " + v.Result + } + if v.Since != "" { + st += ", с " + v.Since + } + out = append(out, watchedUnit{Key: v.Unit, Title: v.Title, Unit: v.Unit, OK: v.OK(), State: st, + Hint: "Перезапустить — Настройка → Обновления → «Службы», кнопка «Перезапустить»; если не поднимается — journalctl -u " + v.Unit + "."}) + } + // служба ключей — когда она нужна: включена в автозапуск (bj включает её при установке ключа) или уже работала; + // на свежем сервере до установки ключа её может и не быть — это не сбой + if _, err := os.Stat("/opt/Validata/VDCSP/bin/amd64/vdcrysvc"); err == nil { + ok := procRunning("vdcrysvc") + unitsWatch.Lock() + seen := unitsWatch.alerted["vdcrysvc"] || unitsWatch.down["vdcrysvc"] > 0 || vdcrysvcSeen + if ok { + vdcrysvcSeen = true + } + unitsWatch.Unlock() + if !ok && !seen && !unitEnabled("vdmkdev.service") { + return out + } + out = append(out, watchedUnit{Key: "vdcrysvc", Title: "служба ключей Валидаты", Unit: "vdmkdev", OK: ok, + State: "процесс vdcrysvc не запущен", + Hint: "Без неё ключ подписи не работает: шлюз НРД не подпишет и не отправит документы. Запустить: sudo /etc/init.d/vdmkdev start."}) + } + return out +} + +// vdcrysvcSeen — служба ключей Валидаты работала с запуска bj (под unitsWatch). +var vdcrysvcSeen bool + +// unitEnabled — служба включена в автозапуск (systemctl is-enabled, без прав root). +func unitEnabled(unit string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, "systemctl", "is-enabled", unit).Output() + return strings.TrimSpace(string(out)) == "enabled" +} + +// procRunning — есть ли процесс с таким именем (по /proc/*/comm, без прав root). +func procRunning(name string) bool { + dirs, _ := filepath.Glob("/proc/[0-9]*/comm") + for _, f := range dirs { + if b, err := os.ReadFile(f); err == nil && strings.TrimSpace(string(b)) == name { + return true + } + } + return false +} + +func unitsWatchTick(rc *RuntimeConfig, units []watchedUnit) { + unitsWatch.Lock() + defer unitsWatch.Unlock() + for _, u := range units { + if u.OK { + if unitsWatch.alerted[u.Key] { + raiseSystemAlert(rc, "Служба «"+u.Title+"» снова работает", "Служба "+u.Unit+" ("+u.Title+") снова запущена.") + } + delete(unitsWatch.down, u.Key) + delete(unitsWatch.alerted, u.Key) + continue + } + unitsWatch.down[u.Key]++ + if unitsWatch.down[u.Key] >= 2 && !unitsWatch.alerted[u.Key] { + unitsWatch.alerted[u.Key] = true + raiseSystemAlert(rc, "Служба «"+u.Title+"» не работает", "Служба "+u.Unit+" ("+u.Title+") не работает: "+u.State+". "+u.Hint) + } + } +} diff --git a/internal/lkgateway/updater.go b/internal/lkgateway/updater.go index a7d23ef..b741358 100644 --- a/internal/lkgateway/updater.go +++ b/internal/lkgateway/updater.go @@ -574,6 +574,7 @@ func (u *Updater) updateLoop(ctx context.Context) { defer cancel() if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate { log.Printf("lk-gateway: %s", st.Message) + u.notifyUpdate(st) } } // первая проверка через минуту после старта @@ -596,6 +597,24 @@ func (u *Updater) updateLoop(ctx context.Context) { } } +// notifyUpdate — администратору системы: вышел выпуск, который на этом сервере ещё не установлен (по разу на выпуск). +func (u *Updater) notifyUpdate(st UpdateStatus) { + cfg := u.rc.Snapshot().Update + if st.Available == "" || cfg.Notified == st.Available { + return + } + cfg.Notified = st.Available + if err := u.rc.SaveUpdateSettings(cfg); err != nil { + return + } + body := "Вышел выпуск bj " + st.Available + ", на этом сервере он ещё не установлен: " + strings.TrimPrefix(st.Message, "доступны обновления: ") + + ". Установить — Настройка → Обновления, кнопка «Обновить»; обмен на время установки прерывается на минуту-две." + if n := strings.TrimSpace(st.Notes); n != "" { + body += "\n\nЧто нового:\n" + n + } + raiseSystemAlert(u.rc, "Доступно обновление bj "+st.Available, body) +} + // ApplyBundle — обновление из файла для закрытого контура: пакет с портала клиента // (подписанный манифест + файлы компонентов). Что ставить, решает тот же план, что и при // обновлении по сети: всё, что новее установленного, в порядке компонентов — ядро первым, diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index 5cb048e..385f95b 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -669,14 +669,12 @@ {{if eq .Step 9}}

Оповещения и резервные копии

-

bj напишет на почту, если НРД не отвечает, перевод застрял{{if .DepoLicensed}}, DEPO не обработал поручение{{end}} или истекает ключ, — и будет сам делать копии сервера. Обмен работает и без этого, но мы советуем настроить оба пункта.

+

bj напишет на почту, если НРД не отвечает, перевод застрял{{if .DepoLicensed}}, DEPO не обработал поручение{{end}} или истекает ключ, — и будет сам делать копии сервера. Настройте почтовый сервер, затем кому писать: администратору системы — о сбоях и обновлениях, отделам — о переводах и поручениях. Обмен работает и без этого, но о сбое тогда никто не узнает вовремя.

{{with .Fancy}} {{template "smtpCard" .}} -
Разослать уведомления отделам — депозитарий, бэк-офис, мидл-офис (необязательно) {{template "notifyGroupsCard" .}} -
{{template "backupCard" .}} {{end}}
diff --git a/internal/lkgateway/web/templates/part_alerts.html b/internal/lkgateway/web/templates/part_alerts.html index 6656935..7ed11de 100644 --- a/internal/lkgateway/web/templates/part_alerts.html +++ b/internal/lkgateway/web/templates/part_alerts.html @@ -2,7 +2,7 @@ {{define "smtpCard"}}

Почта (SMTP)

-

Через какой почтовый сервер bj отправляет письма и кому.

+

Через какой почтовый сервер bj отправляет письма. Кому — в блоке «Кому писать».

@@ -19,7 +19,6 @@
-
@@ -41,24 +40,26 @@ {{define "notifyGroupsCard"}}
-

Адресаты уведомлений: депозитарий, бэк-офис, мидл-офис

-

Три группы адресов и таблица «какой тип уведомления кому отправлять». Письма идут через SMTP из раздела «Почта». Адрес алертов из раздела «Почта» получает системные оповещения всегда; группы ниже добавляются по маршруту.

+

Кому писать

+

Администратору системы — то, что нужно ИТ: сбои шлюза НРД и служб bj, потеря связи с НРД, обновления, лицензия и сертификаты. Отделам — переводы и поручения. Что кому отправлять — в таблице; группе без адреса bj не пишет.

-
+
- +
+
{{range .NotifyGroups}}{{end}} - {{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}{{range $g := $.NotifyGroups}}{{end}}{{end}} + {{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}{{range $g := $.NotifyGroups}}{{end}}{{end}}
Тип уведомления{{.Title}}
{{$k.Title}}
{{$k.Title}}
+
- +
-

Требуется настроенный и включённый SMTP в разделе «Почта».

+

Письма уходят через почтовый сервер из блока «Почта (SMTP)».

{{end}} diff --git a/internal/lkgateway/web/templates/part_backup.html b/internal/lkgateway/web/templates/part_backup.html index d7d342a..d66dc13 100644 --- a/internal/lkgateway/web/templates/part_backup.html +++ b/internal/lkgateway/web/templates/part_backup.html @@ -9,9 +9,10 @@
- - + +
+

Например \\10.10.10.11\backup — та же папка, что открывается в проводнике Windows с этим логином. Копии ложатся в подпапку внутри неё.