Оповещения: группа «Администратор системы», блок «Кому писать» открыт; копии: адрес хранилища как в проводнике
Администратору (адрес из «Почты») — сбои (ИШ, связь с НРД, службы bj, DEPO) и обновления (новый выпуск bj, ИШ, лицензия, сертификаты); отделам — переводы и поручения. Существующие установки: администратор получает всё, что получал. Новые оповещения: «Доступно обновление bj» (раз на выпуск), «Служба … не работает / снова работает» (модули и служба ключей Валидаты). Резервные копии: \\сервер\папка или сервер + общая папка по полям → //сервер/папка; понятная причина отказа доступа. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -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()) },
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -222,8 +222,8 @@ code{font-size:12px}
|
||||
</form>
|
||||
<details style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">Переустановка сервера? Восстановить из резервной копии</summary>
|
||||
<p class="hint">Вместо создания администратора можно вернуть сервер из резервной копии: настройки, пользователи, лицензия, заявки, база и папка шлюза НРД. Нужен одноразовый код (поле вверху) и доступ к хранилищу копий.</p>
|
||||
<label>Хранилище (SMB)</label><input id="rs-share" placeholder="//nas/backup" autocomplete="off">
|
||||
<label>Папка</label><input id="rs-dir" placeholder="bj-backup" autocomplete="off">
|
||||
<label>Сетевая папка — как в проводнике</label><input id="rs-share" placeholder="\\nas\backup" autocomplete="off">
|
||||
<label>Подпапка для копий</label><input id="rs-dir" placeholder="bj-backup" autocomplete="off">
|
||||
<label>Логин хранилища</label><input id="rs-user" autocomplete="off">
|
||||
<label>Пароль хранилища</label><input id="rs-pass" type="password" autocomplete="new-password">
|
||||
<button type="button" id="rs-list">Показать копии</button>
|
||||
|
||||
@@ -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 собирает текст письма депозитарию.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+43
-15
@@ -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; возвращает (код, тело).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 — обновление из файла для закрытого контура: пакет с портала клиента
|
||||
// (подписанный манифест + файлы компонентов). Что ставить, решает тот же план, что и при
|
||||
// обновлении по сети: всё, что новее установленного, в порядке компонентов — ядро первым,
|
||||
|
||||
@@ -669,14 +669,12 @@
|
||||
{{if eq .Step 9}}
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Оповещения и резервные копии</h2>
|
||||
<p>bj напишет на почту, если НРД не отвечает, перевод застрял{{if .DepoLicensed}}, DEPO не обработал поручение{{end}} или истекает ключ, — и будет сам делать копии сервера. Обмен работает и без этого, но мы советуем настроить оба пункта.</p>
|
||||
<p>bj напишет на почту, если НРД не отвечает, перевод застрял{{if .DepoLicensed}}, DEPO не обработал поручение{{end}} или истекает ключ, — и будет сам делать копии сервера. Настройте почтовый сервер, затем кому писать: администратору системы — о сбоях и обновлениях, отделам — о переводах и поручениях. Обмен работает и без этого, но о сбое тогда никто не узнает вовремя.</p>
|
||||
<style>.wiz9 label:not(:has(input[type=checkbox])){display:block;margin:0}.wiz9 label input:not([type=checkbox]),.wiz9 label select,.wiz9 .grid input,.wiz9 .grid select{width:100%;box-sizing:border-box;margin-top:4px}</style>
|
||||
<div class="wiz9">
|
||||
{{with .Fancy}}
|
||||
{{template "smtpCard" .}}
|
||||
<details style="margin:10px 0 0"><summary style="cursor:pointer;color:var(--accent);font-size:13px">Разослать уведомления отделам — депозитарий, бэк-офис, мидл-офис (необязательно)</summary>
|
||||
{{template "notifyGroupsCard" .}}
|
||||
</details>
|
||||
{{template "backupCard" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{{define "smtpCard"}}
|
||||
<div class="card" id="sec-smtp">
|
||||
<h2>Почта (SMTP)</h2>
|
||||
<p class="muted">Через какой почтовый сервер bj отправляет письма и кому.</p>
|
||||
<p class="muted">Через какой почтовый сервер bj отправляет письма. Кому — в блоке «Кому писать».</p>
|
||||
<form method="post" action="/admin/setup/alerts-smtp" style="margin-top:12px">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-bottom:12px"><input type="checkbox" name="enabled" style="width:auto" {{if .Settings.Alerts.SMTP.Enabled}}checked{{end}}> Включить почтовые уведомления</label>
|
||||
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
|
||||
@@ -19,7 +19,6 @@
|
||||
<div><label>Логин</label><input type="text" name="username" value="{{.Settings.Alerts.SMTP.Username}}" autocomplete="off" style="margin-top:3px"></div>
|
||||
<div><label>Пароль {{if .Settings.Alerts.SMTP.Password}}<span class="muted">(задан; пусто — оставить)</span>{{end}}</label><input type="password" name="password" autocomplete="new-password" style="margin-top:3px"></div>
|
||||
<div><label>Отправитель (From)</label><input type="text" name="from" value="{{.Settings.Alerts.SMTP.From}}" placeholder="bj@example.ru" style="margin-top:3px"></div>
|
||||
<div><label>Получатели (через запятую)</label><input type="text" name="to" value="{{.Settings.Alerts.SMTP.To}}" placeholder="ops@example.ru, dev@example.ru" style="margin-top:3px"></div>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
@@ -41,24 +40,26 @@
|
||||
|
||||
{{define "notifyGroupsCard"}}
|
||||
<div class="card" id="sec-notify-groups">
|
||||
<h2>Адресаты уведомлений: депозитарий, бэк-офис, мидл-офис</h2>
|
||||
<p class="muted">Три группы адресов и таблица «какой тип уведомления кому отправлять». Письма идут через SMTP из раздела «Почта». Адрес алертов из раздела «Почта» получает системные оповещения всегда; группы ниже добавляются по маршруту.</p>
|
||||
<h2>Кому писать</h2>
|
||||
<p class="muted">Администратору системы — то, что нужно ИТ: сбои шлюза НРД и служб bj, потеря связи с НРД, обновления, лицензия и сертификаты. Отделам — переводы и поручения. Что кому отправлять — в таблице; группе без адреса bj не пишет.</p>
|
||||
<form method="post" action="/admin/setup/incoming-notify" style="margin-top:12px">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;margin-bottom:12px"><input type="checkbox" name="enabled" style="width:auto" {{if .Settings.Incoming.Enabled}}checked{{end}}> Включить уведомления о переводах</label>
|
||||
<div style="display:grid;gap:10px;max-width:640px">
|
||||
<div><label>Администратор системы (через запятую)</label><input type="text" name="emails_admin" value="{{.Settings.Alerts.SMTP.To}}" placeholder="it@example.ru" style="margin-top:3px;width:100%"></div>
|
||||
<div><label>Депозитарий (через запятую)</label><input type="text" name="emails" value="{{.Settings.Incoming.Emails}}" placeholder="depo@example.ru" style="margin-top:3px;width:100%"></div>
|
||||
<div><label>Бэк-офис (через запятую)</label><input type="text" name="emails_back" value="{{.Settings.Incoming.EmailsBack}}" placeholder="backoffice@example.ru" style="margin-top:3px;width:100%"></div>
|
||||
<div><label>Мидл-офис (через запятую)</label><input type="text" name="emails_middle" value="{{.Settings.Incoming.EmailsMiddle}}" placeholder="middle@example.ru" style="margin-top:3px;width:100%"></div>
|
||||
</div>
|
||||
<table style="margin-top:14px;max-width:760px">
|
||||
<div style="overflow-x:auto;margin-top:14px">
|
||||
<table style="max-width:760px">
|
||||
<thead><tr><th>Тип уведомления</th>{{range .NotifyGroups}}<th style="text-align:center">{{.Title}}</th>{{end}}</tr></thead>
|
||||
<tbody>{{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}<tr><td>{{$k.Title}}</td>{{range $g := $.NotifyGroups}}<td style="text-align:center"><input type="checkbox" name="route_{{$k.Key}}_{{$g.Key}}" value="1" {{if $inc.RouteHas $k.Key $g.Key}}checked{{end}} style="width:auto"></td>{{end}}</tr>{{end}}</tbody>
|
||||
<tbody>{{$inc := .Settings.Incoming}}{{range $k := .NotifyKinds}}<tr><td>{{$k.Title}}</td>{{range $g := $.NotifyGroups}}<td style="text-align:center"><input type="checkbox" name="route_{{$k.Key}}_{{$g.Key}}" value="1" {{if $inc.RouteHas $k.Key $g.Key}}checked{{end}} style="width:auto" aria-label="{{$k.Title}} — {{$g.Title}}"></td>{{end}}</tr>{{end}}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
<button type="submit" formaction="/admin/setup/incoming-notify-test" class="btn btn-secondary">Отправить пробное уведомление</button>
|
||||
<button type="submit" formaction="/admin/setup/incoming-notify-test" class="btn btn-secondary">Отправить пробное уведомление о переводе</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="muted" style="font-size:12px;margin-top:8px">Требуется настроенный и включённый SMTP в разделе «Почта».</p>
|
||||
<p class="muted" style="font-size:12px;margin-top:8px">Письма уходят через почтовый сервер из блока «Почта (SMTP)».</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
<form method="post" action="/admin/setup/backup" id="bk-form" style="display:grid;gap:10px;max-width:640px">
|
||||
<label class="chk" style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="enabled" style="width:auto"{{if $b.Enabled}} checked{{end}}> <b>Делать копии по расписанию</b></label>
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:10px">
|
||||
<label>Хранилище (SMB)<input name="share" value="{{$b.Share}}" placeholder="//nas/backup"></label>
|
||||
<label>Папка<input name="dir" value="{{$b.Dir}}" placeholder="bj-backup"></label>
|
||||
<label>Сетевая папка — как в проводнике<input name="share" value="{{smbWin $b.Share}}" placeholder="\\nas\backup"></label>
|
||||
<label>Подпапка для копий<input name="dir" value="{{$b.Dir}}" placeholder="bj-backup"></label>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;margin:-4px 0 0">Например <code>\\10.10.10.11\backup</code> — та же папка, что открывается в проводнике Windows с этим логином. Копии ложатся в подпапку внутри неё.</p>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
|
||||
<label>Логин хранилища<input name="user" value="{{$b.User}}" autocomplete="off"></label>
|
||||
<label>Пароль {{if $b.PasswordSet}}<span class="muted">(задан; пусто — оставить)</span>{{end}}<input name="password" type="password" autocomplete="new-password"></label>
|
||||
|
||||
Reference in New Issue
Block a user