Первичная настройка, часть 2: шаг 9 «Оповещения и резервные копии», шаг 10 — итоговая проверка готовности (живые проверки со ссылкой «Исправить»); проверка модуля неторговых поручений QUIK (объекты M2M, инструменты, справочник получателей, цикл, ошибки операций); восстановление из копии на /first-run по одноразовому коду; возврат на тот же шаг мастера после «Сохранить» (return_to с ?step); пароль SMTP не выводится в страницу; карточки почты, адресатов и копий — общие для настроек и мастера

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 16:18:28 +03:00
parent fdf0ddd498
commit 17df53f25b
15 changed files with 847 additions and 155 deletions
+2
View File
@@ -861,6 +861,8 @@ func authExempt(p string) bool {
switch { switch {
case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run": case p == "/login" || p == "/logout" || p == "/healthz" || p == "/first-run":
return true return true
case strings.HasPrefix(p, "/first-run/"): // восстановление из копии при переустановке — по одноразовому коду
return true
case strings.HasPrefix(p, "/api/"): case strings.HasPrefix(p, "/api/"):
return true return true
case strings.HasPrefix(p, "/admin/npr/static/"): case strings.HasPrefix(p, "/admin/npr/static/"):
+109
View File
@@ -44,7 +44,69 @@ type firstRunData struct {
var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML)) var firstRunTpl = template.Must(template.New("first-run").Parse(firstRunHTML))
// firstRunCodeOK — одноразовый код первичной настройки верен (с ограничением попыток по адресу).
func firstRunCodeOK(r *http.Request) (bool, string) {
key := "first-run|" + clientIP(r)
if loginFailCount(key) >= 8 {
return false, "Слишком много неудачных попыток. Подождите 15 минут."
}
code := strings.NewReplacer("-", "", " ", "").Replace(strings.TrimSpace(r.FormValue("code")))
var st firstRunState
raw, err := os.ReadFile(firstRunFile)
if err == nil {
_ = json.Unmarshal(raw, &st)
}
if err != nil || st.CodeHash == "" || bcrypt.CompareHashAndPassword([]byte(st.CodeHash), []byte(code)) != nil {
loginFailAdd(key)
time.Sleep(700 * time.Millisecond)
log.Printf("auth: первичная настройка: неверный код с %s", clientIP(r))
return false, "Неверный одноразовый код"
}
return true, ""
}
// registerFirstRunRestore — восстановление из резервной копии прямо со страницы первичной настройки: при
// переустановке сервера не нужно заводить администратора, которого восстановление тут же заменит.
func registerFirstRunRestore(mux *http.ServeMux) {
op := func(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if !firstRunPending() {
jsonOut(w, map[string]any{"ok": false, "error": "первичная настройка уже выполнена — восстановление доступно администратору в мастере настройки"})
return
}
if ok, msg := firstRunCodeOK(r); !ok {
jsonOut(w, map[string]any{"ok": false, "error": msg})
return
}
ctx, cancel := context.WithTimeout(r.Context(), time.Minute)
defer cancel()
if name == "restore" {
log.Printf("auth: первичная настройка: восстановление копии %s с %s", r.FormValue("name"), clientIP(r))
}
jsonOut(w, opResult(backupOp(ctx, name, backupParams(r))))
}
}
mux.HandleFunc("/first-run/backup/list", op("list"))
mux.HandleFunc("/first-run/backup/restore", op("restore"))
mux.HandleFunc("/first-run/backup/status", func(w http.ResponseWriter, r *http.Request) {
out := map[string]any{}
if raw, err := os.ReadFile(restoreStatusFile); err == nil {
var rs map[string]any
if json.Unmarshal(raw, &rs) == nil {
out["restore"] = rs
}
}
out["pending"] = firstRunPending()
jsonOut(w, out)
})
}
func registerFirstRun(mux *http.ServeMux) { func registerFirstRun(mux *http.ServeMux) {
registerFirstRunRestore(mux)
mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/first-run", func(w http.ResponseWriter, r *http.Request) {
if !firstRunPending() { if !firstRunPending() {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
@@ -151,4 +213,51 @@ code{font-size:12px}
{{if .Error}}<div class="err">{{.Error}}</div>{{end}} {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div> <div class="hint">Код потерян или истёк: на сервере выполните <code>sudo bj-installer first-run-code</code>.</div>
</form> </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-user" autocomplete="off">
<label>Пароль хранилища</label><input id="rs-pass" type="password" autocomplete="new-password">
<button type="button" id="rs-list">Показать копии</button>
<label id="rs-lbl" style="display:none">Копия</label><select id="rs-name" style="display:none;width:100%;padding:9px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--text)"></select>
<button type="button" id="rs-go" style="display:none;background:var(--err)">Восстановить</button>
<div id="rs-out" class="hint"></div>
</details>
<script>
(function(){
var out=document.getElementById('rs-out'), sel=document.getElementById('rs-name');
function v(id){return document.getElementById(id).value.trim()}
function params(){ var p=new URLSearchParams(); p.set('code', document.querySelector('input[name=code]').value);
p.set('share',v('rs-share')); p.set('dir',v('rs-dir')); p.set('user',v('rs-user')); p.set('password',document.getElementById('rs-pass').value); return p; }
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
function post(u,p){ return fetch(u,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}); }
document.getElementById('rs-list').onclick=function(){
if(!document.querySelector('input[name=code]').value.trim()){ out.textContent='Сначала введите одноразовый код (вверху).'; return; }
out.textContent='Читаю список копий…';
post('/first-run/backup/list',params()).then(function(d){
if(!d.ok){ out.textContent='✗ '+d.error; return; }
if(!d.list||!d.list.length){ out.textContent='На хранилище копий нет.'; return; }
sel.innerHTML=d.list.map(function(n){return '<option value="'+n+'">'+nice(n)+'</option>'}).join('');
sel.style.display='block'; document.getElementById('rs-lbl').style.display='block'; document.getElementById('rs-go').style.display='block';
out.textContent='Найдено копий: '+d.list.length+'. Обычно выбирают самую свежую.';
}).catch(function(){ out.textContent='Не удалось получить список.'; });
};
document.getElementById('rs-go').onclick=function(){
if(!confirm('Восстановить копию от '+nice(sel.value)+'? Данные этого сервера будут заменены, bj и шлюз НРД перезапустятся.')) return;
var p=params(); p.set('name',sel.value); out.textContent='Запускаю восстановление…';
post('/first-run/backup/restore',p).then(function(d){
if(!d.ok){ out.textContent='✗ '+d.error; return; }
var t0=Date.now(), iv=setInterval(function(){
fetch('/first-run/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
var r=s.restore; if(!r) return; out.textContent='Восстановление: '+r.msg;
if(r.stage==='done'){ clearInterval(iv); out.textContent='✓ '+r.msg+'. Сейчас откроется вход — войдите учётной записью из копии.'; setTimeout(function(){location.href='/login'},4000); }
if(r.stage==='error'){ clearInterval(iv); out.textContent='✗ '+r.msg; }
}).catch(function(){ out.textContent='bj перезапускается, жду…'; });
if(Date.now()-t0>900000) clearInterval(iv);
},3000);
});
};
})();
</script>
</div></body></html>` </div></body></html>`
+30
View File
@@ -1,6 +1,8 @@
package lkgateway package lkgateway
import ( import (
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -75,3 +77,31 @@ func TestStoreCertKindAndExpiry(t *testing.T) {
t.Fatalf("отметка 7 не сохранена: %s", raw) t.Fatalf("отметка 7 не сохранена: %s", raw)
} }
} }
func TestSafeReturnPathKeepsStep(t *testing.T) {
for in, want := range map[string]string{
"/admin/wizard?step=4": "/admin/wizard?step=4",
"/admin/wizard?step=3&sub=done&flash=x": "/admin/wizard?step=3&sub=done",
"/admin/setup#sec-org": "/admin/setup",
"/admin/wizard?step=4%0d%0aX": "/admin/wizard",
"//evil.example/admin": "",
"/admin/x?a=<script>": "/admin/x",
} {
if got := safeReturnPath(in); got != want {
t.Errorf("safeReturnPath(%q)=%q, want %q", in, got, want)
}
}
}
func TestFirstRunRestoreNeedsCode(t *testing.T) {
if !authExempt("/first-run/backup/list") || authExempt("/first-runx") {
t.Fatal("пути восстановления при первичной настройке должны быть без входа, но не шире")
}
mux := http.NewServeMux()
registerFirstRunRestore(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/first-run/backup/restore", strings.NewReader("code=1234&name=2026-10-05_1445")))
if !strings.Contains(rec.Body.String(), `"ok":false`) {
t.Fatalf("без ожидающей первичной настройки восстановление не должно запускаться: %s", rec.Body.String())
}
}
+175
View File
@@ -0,0 +1,175 @@
package lkgateway
// Проверка модуля неторговых поручений QUIK (МНП) — только чтение (05.10.2026). QUIK у депозитариев
// стандартный, поэтому проверяется то, без чего цикл M2M в МНП не работает: объекты API, инструменты
// классов INSTROUTM2M/INSTRINM2M, справочник депозитариев-получателей, включённый автоматический цикл и
// ошибки последних операций. Вызывается кнопкой на шаге «QUIK МНП» и в итоговой проверке готовности.
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/lkgateway/fsql"
)
// MNPCheck — одна строка проверки МНП.
type MNPCheck struct {
Title string `json:"title"`
State string `json:"state"` // ok | warn | err
Detail string `json:"detail"`
}
func quikMNPCheck(ctx context.Context, s QuikMNPSettings) []MNPCheck {
var out []MNPCheck
add := func(title, state, detail string) { out = append(out, MNPCheck{title, state, detail}) }
if strings.TrimSpace(s.Server) == "" {
add("Подключение к базе МНП", "err", "сервер МНП не указан")
return out
}
res, err := quikProbe(ctx, s)
if err != nil {
add("Подключение к базе МНП", "err", quikErrorDetail(err))
return out
}
add("Подключение к базе МНП", "ok", fmt.Sprintf("база %s, логин %s", res.DBName, res.Login))
var miss []string
for _, k := range []string{"table:Export", "table:M2MDepoRecipients", "table:Contracts", "proc:sp_NewInstrSec", "proc:sp_SignInstrSec", "proc:cancel_instruction"} {
if !res.Objects[k] {
miss = append(miss, strings.SplitN(k, ":", 2)[1])
}
}
if len(miss) > 0 {
add("Объекты API МНП для M2M", "err", "под этим логином не видно: "+strings.Join(miss, ", ")+". Нужна версия МНП с поддержкой M2M и права логина (роль InstrEu) — к администратору QUIK / ARQA")
} else {
add("Объекты API МНП для M2M", "ok", "таблицы и процедуры поручений M2M на месте")
}
conn, err := quikOpen(s)
if err != nil {
add("Инструменты M2M", "err", quikErrorDetail(err))
return out
}
defer conn.Close()
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
found := map[string]string{}
if rows, err := conn.QueryContext(cctx, fsql.Qk01); err == nil {
for rows.Next() {
var cls, sec string
if rows.Scan(&cls, &sec) == nil && found[cls] == "" {
found[cls] = sec
}
}
rows.Close()
} else {
add("Инструменты M2M", "err", "справочник инструментов: "+quikErrorDetail(err))
}
inst := func(cls, manual, what string) {
switch {
case strings.TrimSpace(manual) != "":
add("Инструмент «"+what+"»", "ok", "задан в настройках: "+manual)
case found[cls] != "":
add("Инструмент «"+what+"»", "ok", "класс "+cls+", инструмент "+found[cls])
default:
add("Инструмент «"+what+"»", "err", "в МНП нет включённого инструмента класса "+cls+" — администратору QUIK: включить класс и типы поручений M2M в QAdmin")
}
}
inst("INSTROUTM2M", s.SecOut, "Вывод ЦБ (M2M)")
inst("INSTRINM2M", s.SecIn, "Ввод ЦБ (M2M)")
var nrec int
if err := conn.QueryRowContext(cctx, "SELECT COUNT(*) FROM M2MDepoRecipients").Scan(&nrec); err != nil {
add("Справочник депозитариев-получателей", "err", quikErrorDetail(err))
} else {
quikRecipientsState.Lock()
total, at, rerr := quikRecipientsState.Total, quikRecipientsState.At, quikRecipientsState.Err
quikRecipientsState.Unlock()
switch {
case nrec == 0:
add("Справочник депозитариев-получателей", "err", "пуст — bj заполнит его по справочнику НРД (раз в 30 мин до 20:00); после первого заполнения модуль МНП нужно перезапустить")
case rerr != "":
add("Справочник депозитариев-получателей", "warn", fmt.Sprintf("в МНП %d записей; последнее обновление с ошибкой: %s", nrec, rerr))
case total > 0 && nrec < total:
add("Справочник депозитариев-получателей", "warn", fmt.Sprintf("в МНП %d из %d депозитариев НРД — bj дополнит при следующей сверке", nrec, total))
default:
d := fmt.Sprintf("%d депозитариев", nrec)
if !at.IsZero() {
d += ", сверен " + at.Format("02.01 15:04")
}
add("Справочник депозитариев-получателей", "ok", d+". Модуль МНП читает справочник при запуске — после первого заполнения его перезапускают")
}
}
switch {
case !s.WritesEnabled:
add("Автоматический цикл M2M", "warn", "запись в МНП выключена — bj не ставит и не снимает блокировки (Настройка → QUIK МНП)")
case s.Emulate:
add("Автоматический цикл M2M", "warn", "включена имитация — шаги проходят без записи в МНП")
case !s.AutoOut && !s.AutoIn:
add("Автоматический цикл M2M", "warn", "запись разрешена, но исходящие и входящие поручения в МНП не регистрируются автоматически")
default:
var on []string
if s.AutoOut {
on = append(on, "исходящие")
}
if s.AutoIn {
on = append(on, "входящие")
}
if s.PollTerminal {
on = append(on, "поручения из терминала")
}
add("Автоматический цикл M2M", "ok", "включён: "+strings.Join(on, ", ")+"; торговый счёт "+quikAccount(s))
}
since := time.Now().AddDate(0, 0, -30)
var n, nerr int
var lastErr *QuikLink
for _, l := range globalQuikLinks.List(500) {
if l.UpdatedAt.Before(since) {
continue
}
n++
if l.State == "error" {
nerr++
if lastErr == nil || l.UpdatedAt.After(lastErr.UpdatedAt) {
lastErr = l
}
}
}
switch {
case n == 0:
add("Операции в МНП за 30 дней", "ok", "ещё не было")
case nerr > 0:
add("Операции в МНП за 30 дней", "warn", fmt.Sprintf("%d, из них с ошибкой %d; последняя (%s): %s", n, nerr, lastErr.UpdatedAt.Format("02.01 15:04"), lastErr.Err))
default:
add("Операции в МНП за 30 дней", "ok", fmt.Sprintf("%d, без ошибок", n))
}
return out
}
func mnpWorst(cs []MNPCheck) string {
w := "ok"
for _, c := range cs {
if c.State == "err" {
return "err"
}
if c.State == "warn" {
w = "warn"
}
}
return w
}
func registerQuikReady(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/quik/mnp-check", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
cs := quikMNPCheck(ctx, rc.Snapshot().Quik)
jsonOut(w, map[string]any{"checks": cs, "state": mnpWorst(cs), "at": time.Now().Format("02.01.2006 15:04:05")})
})
}
+274
View File
@@ -0,0 +1,274 @@
package lkgateway
// Готовность к работе — итоговый шаг мастера (05.10.2026): все проверки на одной странице, каждая со
// ссылкой на шаг или раздел, где это исправляется. Обязательные (без них обмен с НРД не работает) —
// красные; желательные (оповещения, копии, время, HTTPS) — жёлтые. Проверки идут параллельно, медленные
// (процедуры DEPO с BJ_PING) не запускаются — берётся последний результат.
import (
"context"
"fmt"
"net/http"
"os/exec"
"sort"
"strings"
"sync"
"time"
)
// ReadyCheck — одна строка итоговой проверки.
type ReadyCheck struct {
Order int `json:"-"`
Title string `json:"title"`
State string `json:"state"` // ok | warn | err | skip
Detail string `json:"detail"`
Link string `json:"link,omitempty"`
Required bool `json:"required"`
}
func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
s := rc.Snapshot()
mnp := moduleAllowed(rc, "quik-mnp")
var (
mu sync.Mutex
out []ReadyCheck
wg sync.WaitGroup
)
add := func(c ReadyCheck) {
mu.Lock()
out = append(out, c)
mu.Unlock()
}
run := func(f func() ReadyCheck) {
wg.Add(1)
go func() {
defer wg.Done()
add(f())
}()
}
ok := func(b bool) string {
if b {
return "ok"
}
return "err"
}
run(func() ReadyCheck {
l := licenseStatus(rc)
c := ReadyCheck{Order: 1, Title: "Лицензия", Link: "/admin/wizard?step=1", Required: true, State: ok(l.Valid), Detail: l.Message}
if l.Valid {
c.Detail = pickStr(l.Tenant, "активирована")
if !l.ContractUntil.IsZero() {
c.Detail += ", поддержка до " + l.ContractUntil.Format("02.01.2006")
}
}
return c
})
run(func() ReadyCheck {
st := checkPostgres(ctx, CheckOptions{PostgresDSN: s.Postgres.DSN, Timeout: 3 * time.Second})
c := ReadyCheck{Order: 2, Title: "База bj", Link: "/admin/wizard?step=2", Required: true, State: ok(st.OK && s.Postgres.DSN != ""), Detail: pickStr(st.Message, dsnSummary(s.Postgres.DSN))}
if s.Postgres.DSN == "" {
c.Detail = "база не подключена — заявки не сохраняются"
}
return c
})
if mnp {
run(func() ReadyCheck { return readyDepo(ctx, rc) })
run(func() ReadyCheck {
c := ReadyCheck{Order: 5, Title: "QUIK — модуль неторговых поручений", Link: "/admin/wizard?step=5", Required: true}
cs := quikMNPCheck(ctx, s.Quik)
c.State = mnpWorst(cs)
var bad []string
for _, x := range cs {
if x.State != "ok" {
bad = append(bad, x.Title+": "+x.Detail)
}
}
if len(bad) == 0 {
c.Detail = "подключение, объекты M2M, инструменты, справочник получателей и автоматический цикл — в порядке"
} else {
c.Detail = strings.Join(bad, "; ")
}
return c
})
}
run(func() ReadyCheck {
c := ReadyCheck{Order: 4, Title: "Организация и реквизиты НРД", Link: "/admin/wizard?step=4", Required: true, State: ok(doneOrg(s))}
if doneOrg(s) {
c.Detail = fmt.Sprintf("%s, ИНН %s, код %s; счёт %s, раздел %s", pickStr(s.NSD.OrgName, "—"), orgINN(s), orgCode(s), s.NSD.AccountID, s.NSD.SectionID)
} else {
c.Detail = "не заданы организация или депозитарные реквизиты в НРД"
}
return c
})
run(func() ReadyCheck {
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
ver, _, _ := validataVersion(cctx)
out, _ := exec.CommandContext(cctx, "systemctl", "is-active", "bj-crypto.service").Output()
svc := strings.TrimSpace(string(out))
c := ReadyCheck{Order: 6, Title: "СКЗИ «Валидата» и служба криптографии", Link: "/admin/wizard?step=6", Required: true}
switch {
case ver == "":
c.State, c.Detail = "err", "Валидата на сервере не найдена"
case svc != "active":
c.State, c.Detail = "err", "Валидата "+ver+", но служба bj-crypto не запущена ("+pickStr(svc, "нет")+")"
default:
c.State, c.Detail = "ok", "Валидата "+ver+", служба bj-crypto работает"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 7, Title: "Сертификат ключа организации", Link: "/admin/wizard?step=7", Required: true}
own := ownValidCerts(storeCertsForUI(s))
if len(own) == 0 {
c.State, c.Detail = "err", "в справочнике ИШ нет действующего сертификата организации — установите ключ мастером установки ключа"
if len(s.NSDCerts.LastStore) == 0 {
c.Detail = "справочник ИШ ещё не прочитан (появится после установки ключа и настройки шлюза)"
}
return c
}
sort.Slice(own, func(i, j int) bool { return own[i].NotAfter.Before(own[j].NotAfter) })
last := own[len(own)-1]
d := certDaysLeft(last.NotAfter)
c.State, c.Detail = "ok", fmt.Sprintf("«%s» до %s (осталось %d дн.)", last.SubjectCN, last.NotAfter.Format("02.01.2006"), d)
if d <= 30 {
c.State, c.Detail = "warn", c.Detail+" — закажите новый ключ в УЦ Московской биржи"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 8, Title: "Шлюз НРД", Link: "/admin/wizard?step=8", Required: true}
up, msg := igateState(ctx, s.NSD.IGWBaseURL)
switch {
case !up:
c.State, c.Detail = "err", msg
case !doneNSD(s):
c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран"
default:
c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile
if down, det := nsdLinkStatus(); down {
c.State, c.Detail = "err", det
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 9, Title: "Оповещения", Link: "/admin/wizard?step=9"}
smtp := s.Alerts.SMTP
switch {
case smtp.Enabled && smtp.Host != "" && smtp.To != "":
c.State, c.Detail = "ok", "почта на "+smtp.To
if smtp.LastMsg != "" {
c.Detail += " (последняя проверка: " + smtp.LastMsg + ")"
}
if strings.HasPrefix(smtp.LastMsg, "ошибка") {
c.State = "warn"
}
case s.Alerts.Zabbix.Enabled:
c.State, c.Detail = "ok", "Zabbix "+s.Alerts.Zabbix.Server
default:
c.State, c.Detail = "warn", "не настроены: о сбоях (НРД не отвечает, недоставленные переводы, ошибки DEPO, срок сертификата) узнают только открыв bj"
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 10, Title: "Резервные копии", Link: "/admin/wizard?step=9"}
b := s.Backup
st := backupStatus()
switch {
case !b.Enabled:
c.State, c.Detail = "warn", "выключены — при сбое сервера настройки, заявки и данные шлюза не восстановить"
case st == nil:
c.State, c.Detail = "warn", fmt.Sprintf("включены (каждые %d ч), копий ещё не было", b.every())
case !st.OK:
c.State, c.Detail = "err", "последняя копия не удалась: "+st.Msg
default:
c.State, c.Detail = "ok", fmt.Sprintf("каждые %d ч, хранение %d дн.; последняя %s", b.every(), b.keep(), backupWhen(st.At))
if t, err := time.Parse(time.RFC3339, st.At); err == nil && time.Since(t) > time.Duration(2*b.every()+1)*time.Hour {
c.State, c.Detail = "warn", c.Detail+" — давно, проверьте таймер bj-backup"
}
}
return c
})
run(func() ReadyCheck {
c := ReadyCheck{Order: 11, Title: "Время сервера", Link: "/admin/setup#time"}
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
out, err := exec.CommandContext(cctx, "timedatectl", "show", "-p", "NTPSynchronized", "--value").Output()
switch {
case err != nil:
c.State, c.Detail = "warn", "состояние синхронизации не получено"
case strings.TrimSpace(string(out)) == "yes":
c.State, c.Detail = "ok", "синхронизировано с сервером времени"
default:
c.State, c.Detail = "warn", "время не синхронизируется — часы уплывут, а это важно для подписей и обмена с НРД; укажите сервер времени (NTP)"
}
return c
})
wg.Wait()
sort.Slice(out, func(i, j int) bool { return out[i].Order < out[j].Order })
return out
}
func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck {
f := rc.Snapshot().Fancy
c := ReadyCheck{Order: 3, Title: "Учёт DEPO", Link: "/admin/wizard?step=3&sub=done", Required: true}
if strings.TrimSpace(f.Server) == "" {
c.State, c.Detail = "err", "база DEPO не подключена"
return c
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
db, err := fancyOpen(f, f.DepoDB())
if err == nil {
err = db.PingContext(cctx)
}
if err != nil {
c.State, c.Detail = "err", "подключение: "+quikErrorDetail(err)
return c
}
defer db.Close()
var notes []string
c.State = "ok"
if !f.DepoTablesCheckOK {
c.State = "err"
notes = append(notes, "таблицы обмена FTV_M2M: "+pickStr(f.DepoTablesCheck, "не проверены"))
}
if msg := depoObjectsMissing(cctx, db, f); msg != "" {
c.State = "err"
notes = append(notes, msg)
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
c.State = "err"
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
}
if jmsg, jok := depoJobCheck(cctx, rc); !jok {
c.State = "err"
notes = append(notes, "запуск обработки: "+jmsg)
}
if n, _, err := depoAccPrefixInfo(cctx, f); err == nil && n == 0 && !f.IsPostgres() {
c.State = "err"
notes = append(notes, "открытых торговых счетов «"+depoAccPrefix()+"…» в учёте нет — проверьте префикс счёта")
}
if len(notes) == 0 {
c.Detail = f.Server + ", база " + f.DepoDB() + ": таблицы, обработка и счета клиентов на месте"
} else {
c.Detail = strings.Join(notes, "; ")
}
return c
}
func registerReadiness(mux *http.ServeMux, rc *RuntimeConfig) {
mux.HandleFunc("/admin/setup/readiness", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
checks := readinessChecks(ctx, rc)
ready := true
for _, c := range checks {
if c.Required && c.State != "ok" && c.State != "warn" {
ready = false
}
}
jsonOut(w, map[string]any{"checks": checks, "ready": ready, "at": time.Now().Format("02.01.2006 15:04:05")})
})
}
+10 -10
View File
@@ -49,16 +49,16 @@ type Settings struct {
// чтение + вызовы штатных хранимых процедур API (роль InstrEu). Прямых INSERT/ // чтение + вызовы штатных хранимых процедур API (роль InstrEu). Прямых INSERT/
// UPDATE в таблицы НЕ делаем. // UPDATE в таблицы НЕ делаем.
type QuikMNPSettings struct { type QuikMNPSettings struct {
Server string `json:"server"` // host или host\instance Server string `json:"server"` // host или host\instance
Port int `json:"port"` // напр. 8849 (нестандартный) Port int `json:"port"` // напр. 8849 (нестандартный)
Database string `json:"database"` // имя БД МНП (если требуется) Database string `json:"database"` // имя БД МНП (если требуется)
User string `json:"user"` // SQL-логин (роль InstrEu), напр. BROKER_88 User string `json:"user"` // SQL-логин (роль InstrEu), напр. BROKER_88
Password string `json:"password"` // пароль Password string `json:"password"` // пароль
Encrypt string `json:"encrypt"` // disable | true (по умолчанию true+trust для боевой) Encrypt string `json:"encrypt"` // disable | true (по умолчанию true+trust для боевой)
Trust bool `json:"trust"` // TrustServerCertificate (самоподписанный серт) Trust bool `json:"trust"` // TrustServerCertificate (самоподписанный серт)
Verified bool `json:"verified"` // последнее тест-подключение прошло Verified bool `json:"verified"` // последнее тест-подключение прошло
LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам) LastReport string `json:"last_report"` // полный текст последнего теста (для копирования коллегам)
LastAt time.Time `json:"last_at"` // время последнего теста LastAt time.Time `json:"last_at"` // время последнего теста
M2MMissing string `json:"m2m_missing,omitempty"` // ключевые объекты M2M, не видные под логином при последней проверке M2MMissing string `json:"m2m_missing,omitempty"` // ключевые объекты M2M, не видные под логином при последней проверке
// Автоматический цикл M2M в МНП (пишущие операции к боевой БД). // Автоматический цикл M2M в МНП (пишущие операции к боевой БД).
+2
View File
@@ -217,6 +217,8 @@ func NewServer(cfg ServerConfig) (*Server, error) {
registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас» registerNSDHealth(mux, rc) // связь с НРД: шапка (json) и «Проверить связь сейчас»
registerBackup(mux, rc) // резервное копирование и восстановление (backup.go) registerBackup(mux, rc) // резервное копирование и восстановление (backup.go)
registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go) registerIgate(mux, rc) // интеграционный шлюз НРД: состояние и установка (igate.go)
registerReadiness(mux, rc) // итоговая проверка готовности (мастер, шаг 10)
registerQuikReady(mux, rc) // проверка модуля неторговых поручений QUIK (только чтение)
registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk) registerLKAPI(mux, rc, svc) // API для модуля личного кабинета клиента (bj-lk)
registerHealth(mux) registerHealth(mux)
registerQuikOps(mux, rc, svc) registerQuikOps(mux, rc, svc)
+29 -8
View File
@@ -14,6 +14,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -188,6 +189,7 @@ type WizardData struct {
Certs bool Certs bool
NSD bool NSD bool
TestRun bool TestRun bool
Alerts bool // оповещения настроены и резервные копии включены
} }
} }
@@ -276,7 +278,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
} }
cancel() cancel()
} }
if step >= 3 && step <= 5 { // DEPO, организация, QUIK МНП — общие блоки из «Настройки» if (step >= 3 && step <= 5) || step == 9 { // DEPO, организация, QUIK МНП, оповещения и копии — общие блоки из «Настройки»
fd := h.setupData(w, r, "") fd := h.setupData(w, r, "")
d.Fancy = &fd d.Fancy = &fd
} }
@@ -287,6 +289,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
} }
} }
d.Done.Org = doneOrg(s) d.Done.Org = doneOrg(s)
d.Done.Alerts = doneAlerts(s)
d.Done.Quik = !d.DepoLicensed || doneQuik(s) d.Done.Quik = !d.DepoLicensed || doneQuik(s)
if step == 3 && d.DepoLicensed && strings.TrimSpace(s.Fancy.Server) != "" { if step == 3 && d.DepoLicensed && strings.TrimSpace(s.Fancy.Server) != "" {
// Есть ли у сервера SQL Agent: у Express его нет, у PostgreSQL — тоже; тогда обработку // Есть ли у сервера SQL Agent: у Express его нет, у PostgreSQL — тоже; тогда обработку
@@ -329,8 +332,8 @@ func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool)
} }
// wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП, // wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП,
// 6 Валидата, 7 сертификаты, 8 шлюз НРД, 9 проверка. // 6 Валидата, 7 сертификаты, 8 шлюз НРД, 9 оповещения и резервные копии, 10 проверка готовности.
const wizardSteps = 9 const wizardSteps = 10
// wizardProgressFile — номер последнего пройденного шага мастера (переход «Далее» на шаг N // wizardProgressFile — номер последнего пройденного шага мастера (переход «Далее» на шаг N
// означает, что шаги до N пройдены). // означает, что шаги до N пройдены).
@@ -998,7 +1001,7 @@ func (h *setupHandlers) saveAlertsSMTP(w http.ResponseWriter, r *http.Request) {
Port: atoiDefault(r.FormValue("port"), 587), Port: atoiDefault(r.FormValue("port"), 587),
Security: nonEmpty(r.FormValue("security"), "starttls"), Security: nonEmpty(r.FormValue("security"), "starttls"),
Username: strings.TrimSpace(r.FormValue("username")), Username: strings.TrimSpace(r.FormValue("username")),
Password: r.FormValue("password"), Password: pickStr(r.FormValue("password"), a.SMTP.Password), // пароль в страницу не выводится: пусто — прежний
From: strings.TrimSpace(r.FormValue("from")), From: strings.TrimSpace(r.FormValue("from")),
To: strings.TrimSpace(r.FormValue("to")), To: strings.TrimSpace(r.FormValue("to")),
LastMsg: a.SMTP.LastMsg, LastMsg: a.SMTP.LastMsg,
@@ -1686,7 +1689,11 @@ func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
if s := r.FormValue("section"); s != "" && isAnchorName(s) { if s := r.FormValue("section"); s != "" && isAnchorName(s) {
q.Set("scrollto", s) q.Set("scrollto", s)
} }
http.Redirect(w, r, dest+"?"+q.Encode(), http.StatusSeeOther) sep := "?"
if strings.Contains(dest, "?") {
sep = "&"
}
http.Redirect(w, r, dest+sep+q.Encode(), http.StatusSeeOther)
} }
// safeReturnPath пропускает только наши же admin-пути ("/admin/..."), без // safeReturnPath пропускает только наши же admin-пути ("/admin/..."), без
@@ -1701,13 +1708,27 @@ func safeReturnPath(p string) string {
if strings.HasPrefix(p, "//") || strings.ContainsAny(p, ":\\\r\n") { if strings.HasPrefix(p, "//") || strings.ContainsAny(p, ":\\\r\n") {
return "" return ""
} }
// только путь, без query/fragment if i := strings.IndexByte(p, '#'); i >= 0 {
if i := strings.IndexAny(p, "?#"); i >= 0 {
p = p[:i] p = p[:i]
} }
return p // путь и безопасные параметры (например ?step=4&sub=done в мастере); flash/scrollto — свежие
path, query, _ := strings.Cut(p, "?")
var keep []string
for _, kv := range strings.Split(query, "&") {
k, v, ok := strings.Cut(kv, "=")
if !ok || k == "flash" || k == "scrollto" || !safeQueryPart.MatchString(k) || !safeQueryPart.MatchString(v) {
continue
}
keep = append(keep, k+"="+v)
}
if len(keep) > 0 {
return path + "?" + strings.Join(keep, "&")
}
return path
} }
var safeQueryPart = regexp.MustCompile(`^[A-Za-z0-9_-]{1,40}$`)
// isAnchorName пропускает только безопасные id-якоря (буквы/цифры/-/_), // isAnchorName пропускает только безопасные id-якоря (буквы/цифры/-/_),
// чтобы не дать заголовкам ответа инжектить произвольное. // чтобы не дать заголовкам ответа инжектить произвольное.
func isAnchorName(s string) bool { func isAnchorName(s string) bool {
+7
View File
@@ -61,6 +61,7 @@ func setupSteps(rc *RuntimeConfig) []SetupStep {
SetupStep{6, "Валидата", validata != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")}, SetupStep{6, "Валидата", validata != "" || (s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.ModulePath != "")},
SetupStep{7, "Сертификаты", certs > 0}, SetupStep{7, "Сертификаты", certs > 0},
SetupStep{8, "Шлюз НРД", doneNSD(s)}, SetupStep{8, "Шлюз НРД", doneNSD(s)},
SetupStep{9, "Оповещения и копии", doneAlerts(s)},
) )
return steps return steps
} }
@@ -94,3 +95,9 @@ func doneNSD(s Settings) bool {
_, err := nsdadapter.LookupProfile(s.NSD.Profile) _, err := nsdadapter.LookupProfile(s.NSD.Profile)
return err == nil return err == nil
} }
// doneAlerts — оповещения уходят наружу (почта или Zabbix) и резервные копии включены.
func doneAlerts(s Settings) bool {
smtp := s.Alerts.SMTP
return ((smtp.Enabled && smtp.Host != "" && smtp.To != "") || s.Alerts.Zabbix.Enabled) && s.Backup.Enabled
}
@@ -94,6 +94,7 @@
<section class="settings-section" id="sec-quik"> <section class="settings-section" id="sec-quik">
<h1>QUIK — Модуль неторговых поручений</h1> <h1>QUIK — Модуль неторговых поручений</h1>
{{template "quikSection" .}} {{template "quikSection" .}}
{{if .Settings.Quik.Server}}{{template "mnpCheck" .}}{{end}}
</section> </section>
{{end}}{{/* /AdvancedUnlocked — конец перспективных модулей Fancy/QUIK */}} {{end}}{{/* /AdvancedUnlocked — конец перспективных модулей Fancy/QUIK */}}
@@ -839,34 +840,7 @@
<h1>Оповещения</h1> <h1>Оповещения</h1>
<p class="muted" style="max-width:60ch">Системные события (сбой или зависание фонового компонента, аварийный перезапуск) всегда попадают в ленту «Новости». Дополнительно их можно доставлять наружу — на почту оператору и/или в мониторинг Zabbix, чтобы узнать о проблеме, даже когда интерфейс bj никто не открыл.</p> <p class="muted" style="max-width:60ch">Системные события (сбой или зависание фонового компонента, аварийный перезапуск) всегда попадают в ленту «Новости». Дополнительно их можно доставлять наружу — на почту оператору и/или в мониторинг Zabbix, чтобы узнать о проблеме, даже когда интерфейс bj никто не открыл.</p>
<div class="card"> {{template "smtpCard" .}}
<h2>Почта (SMTP)</h2>
<p class="muted">Отправка уведомлений по e-mail. Пароль хранится в конфигурации сервиса.</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">
<div><label>SMTP-сервер</label><input type="text" name="host" value="{{.Settings.Alerts.SMTP.Host}}" placeholder="smtp.example.ru" style="margin-top:3px"></div>
<div><label>Порт</label><input type="text" id="smtp-port" name="port" value="{{if .Settings.Alerts.SMTP.Port}}{{.Settings.Alerts.SMTP.Port}}{{else}}587{{end}}" style="margin-top:3px"></div>
<div><label>Шифрование</label>
<select id="smtp-security" name="security" style="margin-top:3px" onchange="smtpSyncPort(this.value)">
<option value="starttls" {{if eq .Settings.Alerts.SMTP.Security "starttls"}}selected{{end}}>STARTTLS (587)</option>
<option value="ssl" {{if eq .Settings.Alerts.SMTP.Security "ssl"}}selected{{end}}>SSL/TLS (465)</option>
<option value="none" {{if eq .Settings.Alerts.SMTP.Security "none"}}selected{{end}}>Без шифрования (25)</option>
</select></div>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:12px">
<div><label>Логин</label><input type="text" name="username" value="{{.Settings.Alerts.SMTP.Username}}" autocomplete="off" style="margin-top:3px"></div>
<div><label>Пароль</label><input type="password" name="password" value="{{.Settings.Alerts.SMTP.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>
<button type="submit" formaction="/admin/setup/alerts-smtp-test" class="btn btn-secondary">Отправить проверочное письмо</button>
</div>
</form>
{{if .Settings.Alerts.SMTP.LastMsg}}<p class="muted" style="font-size:12px;margin-top:10px">Последняя проверка: {{.Settings.Alerts.SMTP.LastMsg}}</p>{{end}}
</div>
<div class="card"> <div class="card">
<h2>Zabbix (хук мониторинга)</h2> <h2>Zabbix (хук мониторинга)</h2>
@@ -887,35 +861,7 @@
{{if .Settings.Alerts.Zabbix.LastMsg}}<p class="muted" style="font-size:12px;margin-top:10px">Последняя проверка: {{.Settings.Alerts.Zabbix.LastMsg}}</p>{{end}} {{if .Settings.Alerts.Zabbix.LastMsg}}<p class="muted" style="font-size:12px;margin-top:10px">Последняя проверка: {{.Settings.Alerts.Zabbix.LastMsg}}</p>{{end}}
</div> </div>
<div class="card"> {{template "notifyGroupsCard" .}}
<h2>Адресаты уведомлений: депозитарий, бэк-офис, мидл-офис</h2>
<p class="muted">Три группы адресов и таблица «какой тип уведомления кому отправлять». Письма идут через SMTP из раздела «Почта». Адрес алертов из раздела «Почта» получает системные оповещения всегда; группы ниже добавляются по маршруту.</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" 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">
<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>
</table>
<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>
</div>
</form>
<p class="muted" style="font-size:12px;margin-top:8px">Требуется настроенный и включённый SMTP в разделе «Почта».</p>
</div>
<script>
// Смена шифрования SMTP авто-подставляет типовой порт; править вручную можно.
function smtpSyncPort(sec){
var p=document.getElementById('smtp-port'); if(!p) return;
var def={starttls:'587', ssl:'465', none:'25'}[sec];
if(def) p.value=def;
}
</script>
</section> </section>
{{/* ============ ОБНОВЛЕНИЯ ============ */}} {{/* ============ ОБНОВЛЕНИЯ ============ */}}
@@ -992,68 +938,7 @@
{{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}} {{/* ============ РЕЗЕРВНОЕ КОПИРОВАНИЕ ============ */}}
<section class="settings-section" id="sec-backup"> <section class="settings-section" id="sec-backup">
<h1>Резервное копирование</h1> <h1>Резервное копирование</h1>
{{$b := .Settings.Backup}}{{$bs := backupStatus}} {{template "backupCard" .}}
<div class="card">
<h2><span class="dot {{if not $b.Enabled}}warn{{else if and $bs $bs.OK}}ok{{else}}err{{end}}"></span>Копии сервера bj на сетевое хранилище</h2>
<p class="muted">Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).</p>
<p style="margin:8px 0">{{if $bs}}Последняя копия: <b>{{backupWhen $bs.At}}</b> — {{if $bs.OK}}<span style="color:var(--ok)">✓ {{$bs.Dir}}, {{$bs.Msg}}</span>{{else}}<span style="color:var(--err)">✗ {{$bs.Msg}}</span>{{end}}{{else}}<span class="muted">Копий ещё не было.</span>{{end}}</p>
<style>#bk-form label:not(.chk){display:block;margin:0}#bk-form label:not(.chk) input,#bk-form label:not(.chk) select{width:100%;box-sizing:border-box;margin-top:4px}</style>
<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>
</div>
<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>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Как часто<select name="every_hours">{{$e := $b.EveryHours}}{{if not $e}}{{$e = 6}}{{end}}{{range $h := intList 1 2 3 4 6 8 12 24}}<option value="{{$h}}"{{if eq $h $e}} selected{{end}}>{{if eq $h 24}}раз в сутки (02:00){{else}}каждые {{$h}} ч{{end}}</option>{{end}}</select></label>
<label>Хранить, дней<input name="keep_days" type="number" min="1" max="365" value="{{if $b.KeepDays}}{{$b.KeepDays}}{{else}}14{{end}}"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" type="submit">Сохранить</button>
<button class="btn" type="button" id="bk-test" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить доступ</button>
<button class="btn" type="button" id="bk-run" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Сделать копию сейчас</button>
<button class="btn" type="button" id="bk-list" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Копии на хранилище</button>
</div>
<div id="bk-out" class="muted" style="font-size:13px"></div>
</form>
<script>
(function(){
var f=document.getElementById('bk-form'), out=document.getElementById('bk-out');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
function post(url, withForm){
var p=new URLSearchParams();
if(withForm){ ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); }
return fetch(url,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()});
}
document.getElementById('bk-test').onclick=function(){ out.textContent='Проверяю доступ…';
post('/admin/setup/backup/test',true).then(function(d){ out.innerHTML=d.ok?'<span style="color:var(--ok)">✓ '+esc(d.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; }); };
document.getElementById('bk-run').onclick=function(){ out.textContent='Запускаю копию…';
post('/admin/setup/backup/run',false).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
out.textContent='Копия делается (обычно до минуты)…'; var t0=Date.now();
var iv=setInterval(function(){ fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
var b=s.backup; if(b && Date.parse(b.at)>=t0-60000){ clearInterval(iv); out.innerHTML=b.ok?'<span style="color:var(--ok)">✓ копия готова: '+esc(b.dir)+', '+esc(b.msg)+'</span>':'<span style="color:var(--err)">✗ '+esc(b.msg)+'</span>'; }
if(Date.now()-t0>600000) clearInterval(iv); }); },3000); }); };
document.getElementById('bk-list').onclick=function(){ out.textContent='Читаю список…';
post('/admin/setup/backup/list',true).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
if(!d.list||!d.list.length){ out.textContent='Копий на хранилище нет.'; return; }
out.innerHTML='Копий: <b>'+d.list.length+'</b>. «Проверить» скачивает копию и сверяет контрольные суммы — ничего не восстанавливает.'+
'<div style="display:grid;gap:4px;margin-top:6px">'+d.list.slice(0,60).map(function(n){
return '<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><code>'+esc(nice(n))+'</code><button type="button" class="btn bk-verify" data-name="'+esc(n)+'" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить</button><span class="bk-vres" style="font-size:12px"></span></div>';
}).join('')+'</div>';
out.querySelectorAll('.bk-verify').forEach(function(btn){ btn.onclick=function(){
var res=btn.nextElementSibling; res.textContent='скачиваю и проверяю…'; btn.disabled=true;
var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(k){ var v=f[k].value.trim(); if(v) p.set(k,v); }); p.set('name',btn.dataset.name);
fetch('/admin/setup/backup/verify',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(v){
btn.disabled=false; res.innerHTML=v.ok?'<span style="color:var(--ok)">✓ '+esc(v.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(v.error)+'</span>'; });
}; }); }); };
})();
</script>
</div>
</section> </section>
<section class="settings-section" id="sec-license"> <section class="settings-section" id="sec-license">
@@ -29,7 +29,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p> <p>Пошаговый мастер первого запуска — состояние сохраняется после каждого шага</p>
</div> </div>
<div class="wizard-progress" style="max-width:860px"> <div class="wizard-progress" style="max-width:940px">
<a href="/admin/wizard?step=1" title="Перейти к шагу 1" class="wstep {{if .Done.License}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.License}}✓{{else}}1{{end}}</span><span class="lbl">Лицензия</span></a> <a href="/admin/wizard?step=1" title="Перейти к шагу 1" class="wstep {{if .Done.License}}done{{end}} {{if eq .Step 1}}current{{end}}"><span class="bub">{{if .Done.License}}✓{{else}}1{{end}}</span><span class="lbl">Лицензия</span></a>
<a href="/admin/wizard?step=2" title="Перейти к шагу 2" class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}2{{end}}</span><span class="lbl">База bj</span></a> <a href="/admin/wizard?step=2" title="Перейти к шагу 2" class="wstep {{if .Done.Postgres}}done{{end}} {{if eq .Step 2}}current{{end}}"><span class="bub">{{if .Done.Postgres}}✓{{else}}2{{end}}</span><span class="lbl">База bj</span></a>
<a href="/admin/wizard?step=3" title="Перейти к шагу 3" class="wstep {{if .Done.Depo}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Depo}}✓{{else}}3{{end}}</span><span class="lbl">Учёт DEPO</span></a> <a href="/admin/wizard?step=3" title="Перейти к шагу 3" class="wstep {{if .Done.Depo}}done{{end}} {{if eq .Step 3}}current{{end}}"><span class="bub">{{if .Done.Depo}}✓{{else}}3{{end}}</span><span class="lbl">Учёт DEPO</span></a>
@@ -38,7 +38,8 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<a href="/admin/wizard?step=6" title="Перейти к шагу 6" class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 6}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}6{{end}}</span><span class="lbl">Валидата</span></a> <a href="/admin/wizard?step=6" title="Перейти к шагу 6" class="wstep {{if .Done.Crypto}}done{{end}} {{if eq .Step 6}}current{{end}}"><span class="bub">{{if .Done.Crypto}}✓{{else}}6{{end}}</span><span class="lbl">Валидата</span></a>
<a href="/admin/wizard?step=7" title="Перейти к шагу 7" class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 7}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}7{{end}}</span><span class="lbl">Сертификаты</span></a> <a href="/admin/wizard?step=7" title="Перейти к шагу 7" class="wstep {{if .Done.Certs}}done{{end}} {{if eq .Step 7}}current{{end}}"><span class="bub">{{if .Done.Certs}}✓{{else}}7{{end}}</span><span class="lbl">Сертификаты</span></a>
<a href="/admin/wizard?step=8" title="Перейти к шагу 8" class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 8}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}8{{end}}</span><span class="lbl">Шлюз НРД</span></a> <a href="/admin/wizard?step=8" title="Перейти к шагу 8" class="wstep {{if .Done.NSD}}done{{end}} {{if eq .Step 8}}current{{end}}"><span class="bub">{{if .Done.NSD}}✓{{else}}8{{end}}</span><span class="lbl">Шлюз НРД</span></a>
<a href="/admin/wizard?step=9" title="Перейти к шагу 9" class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 9}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}9{{end}}</span><span class="lbl">Проверка</span></a> <a href="/admin/wizard?step=9" title="Перейти к шагу 9" class="wstep {{if .Done.Alerts}}done{{end}} {{if eq .Step 9}}current{{end}}"><span class="bub">{{if .Done.Alerts}}✓{{else}}9{{end}}</span><span class="lbl">Оповещения</span></a>
<a href="/admin/wizard?step=10" title="Перейти к шагу 10" class="wstep {{if .Done.TestRun}}done{{end}} {{if eq .Step 10}}current{{end}}"><span class="bub">{{if .Done.TestRun}}✓{{else}}10{{end}}</span><span class="lbl">Проверка</span></a>
</div> </div>
<p style="text-align:center;margin:-14px 0 18px;font-size:13px"><a href="/admin/">Пропустить мастер и перейти в систему →</a> <span class="muted">— вернуться к нему можно из «Настройка → Мастер настройки»</span></p> <p style="text-align:center;margin:-14px 0 18px;font-size:13px"><a href="/admin/">Пропустить мастер и перейти в систему →</a> <span class="muted">— вернуться к нему можно из «Настройка → Мастер настройки»</span></p>
@@ -366,6 +367,7 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
<strong>Если МНП уже есть.</strong> Адрес сервера и учётные данные — у вашего администратора QUIK.</div> <strong>Если МНП уже есть.</strong> Адрес сервера и учётные данные — у вашего администратора QUIK.</div>
{{if not .DepoLicensed}}<p class="muted">Модуль неторговых поручений не входит в текущую лицензию — шаг пропускается.</p> {{if not .DepoLicensed}}<p class="muted">Модуль неторговых поручений не входит в текущую лицензию — шаг пропускается.</p>
{{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}} {{else}}{{with .Fancy}}{{template "quikSection" .}}{{end}}
{{if .Settings.Quik.Server}}{{template "mnpCheck" .}}{{end}}
{{if .Settings.Quik.M2MMissing}}<p style="color:var(--err);margin:8px 0">✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.</p>{{end}} {{if .Settings.Quik.M2MMissing}}<p style="color:var(--err);margin:8px 0">✗ Подключение есть, но под этим логином не видно объектов M2M: {{.Settings.Quik.M2MMissing}}. Проверьте имя базы МНП и права логина (роль InstrEu) и нажмите «Проверить» ещё раз.</p>{{end}}
<div class="help-block" style="margin-top:12px"><strong>Справочник депозитариев-получателей.</strong> Чтобы QUIK принимал поручения M2M, в МНП должен быть справочник депозитариев-получателей (<code>M2MDepoRecipients</code>): bj заполняет его сам по справочнику участников НРД. <b>Модуль МНП читает справочник только при запуске</b> — после первого заполнения попросите администратора QUIK перезапустить модуль неторговых поручений, иначе QUIK ответит «Неизвестный депозитарий получателя».<br><span class="muted">{{quikRecipientsInfo}}</span></div>{{end}} <div class="help-block" style="margin-top:12px"><strong>Справочник депозитариев-получателей.</strong> Чтобы QUIK принимал поручения M2M, в МНП должен быть справочник депозитариев-получателей (<code>M2MDepoRecipients</code>): bj заполняет его сам по справочнику участников НРД. <b>Модуль МНП читает справочник только при запуске</b> — после первого заполнения попросите администратора QUIK перезапустить модуль неторговых поручений, иначе QUIK ответит «Неизвестный депозитарий получателя».<br><span class="muted">{{quikRecipientsInfo}}</span></div>{{end}}
<div style="margin-top:20px;display:flex;justify-content:space-between"> <div style="margin-top:20px;display:flex;justify-content:space-between">
@@ -499,11 +501,34 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
</div> </div>
{{end}} {{end}}
{{/* ============= ШАГ 5: Тест-ран (режим разработки) / проверка подключений ============= */}} {{/* ============= ШАГ 9: Оповещения и резервные копии ============= */}}
{{if eq .Step 9}} {{if eq .Step 9}}
<div class="card">
<h2><span class="dot {{if .Done.Alerts}}ok{{else}}warn{{end}}"></span>Шаг 9. Оповещения и резервные копии</h2>
<div class="help-block"><strong>Зачем.</strong> bj сам следит за обменом: НРД не отвечает, перевод не доставлен, ошибка обработки в DEPO, истекает сертификат ключа. Чтобы об этом узнали сразу, а не открыв bj, — укажите почту. Резервные копии позволят восстановить сервер целиком (настройки, заявки, шлюз НРД) после сбоя или переустановки.<br>
<strong>Можно позже.</strong> Шаг необязательный — обмен работает и без него, но мы настоятельно советуем настроить оба пункта.</div>
<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}}
<h3 style="margin:16px 0 6px">Почта для оповещений</h3>
{{template "smtpCard" .}}
{{template "notifyGroupsCard" .}}
<h3 style="margin:16px 0 6px">Резервные копии</h3>
{{template "backupCard" .}}
{{end}}
</div>
<div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=8" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 8</a>
<a href="/admin/wizard?step=10" class="btn" style="{{if not .Done.Alerts}}background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);{{end}}text-decoration:none">{{if .Done.Alerts}}К проверке →{{else}}Пропустить →{{end}}</a>
</div>
</div>
{{end}}
{{/* ============= ШАГ 10: Проверка готовности (в режиме разработки — ещё тестовая заявка) ============= */}}
{{if eq .Step 10}}
<div class="card"> <div class="card">
{{if devOn}} {{if devOn}}
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 9. Тестовая заявка</h2> <h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 10. Тестовая заявка</h2>
<p>Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.</p> <p>Прогон полного цикла: создание заявки → валидация → подпись → отправка в НРД (или mock) → ожидание Decision → подтверждение.</p>
<div class="help-block"> <div class="help-block">
@@ -523,23 +548,33 @@ a.wstep:hover .bub { box-shadow:0 0 0 4px var(--accent-weak); }
{{if .Settings.LastTest.Message}}<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>{{end}} {{if .Settings.LastTest.Message}}<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>{{end}}
</table> </table>
{{end}} {{end}}
{{else}}
<h2><span class="dot {{if .Done.TestRun}}ok{{else}}err{{end}}"></span>Шаг 9. Проверка подключений</h2>
<p>Проверьте, что база данных, СКЗИ и шлюз НРД доступны: на странице «Статус» bj проверяет каждое подключение и показывает, что нужно исправить.</p>
<a href="/admin/status" class="btn" style="background:var(--ok);font-size:15px;padding:10px 20px;text-decoration:none;display:inline-block;margin-top:12px">Открыть проверку подключений →</a>
{{end}} {{end}}
{{if not devOn}}<h2><span class="dot" id="rd-dot"></span>Шаг 10. Готовность к работе</h2>{{else}}<h3 style="margin-top:18px">Готовность к работе</h3>{{end}}
<p class="muted" style="margin:4px 0 10px">bj проверяет всё, что нужно для обмена с НРД, прямо сейчас. Красное — обмен работать не будет, жёлтое — работать будет, но лучше исправить. У каждой строки — ссылка туда, где это исправляется.</p>
<div id="rd-sum" style="font-weight:600;margin:6px 0">Проверяю…</div>
<table id="rd-tab" style="width:100%"><tbody></tbody></table>
<p style="margin-top:8px"><button type="button" class="btn" id="rd-again" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8)">Проверить ещё раз</button> <span class="muted" id="rd-at" style="font-size:12px"></span></p>
<script>
(function(){
var tb=document.querySelector('#rd-tab tbody'), sum=document.getElementById('rd-sum'), dot=document.getElementById('rd-dot');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
var ic={ok:'<span style="color:var(--ok)">✓</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>',skip:'<span class="muted">—</span>'};
function load(){ sum.textContent='Проверяю…'; tb.innerHTML='';
fetch('/admin/setup/readiness',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
tb.innerHTML=d.checks.map(function(c){return '<tr><td style="width:24px;vertical-align:top">'+ic[c.state]+'</td><td style="vertical-align:top;white-space:nowrap;padding-right:10px"><b>'+esc(c.title)+'</b></td><td style="vertical-align:top">'+esc(c.detail)+(c.state!=='ok'&&c.link?' <a href="'+esc(c.link)+'">Исправить →</a>':'')+'</td></tr>'}).join('');
var red=d.checks.filter(function(c){return c.state==='err'}).length, yel=d.checks.filter(function(c){return c.state==='warn'}).length;
sum.innerHTML=d.ready?(yel?'<span style="color:var(--ok)">✓ Обмен с НРД готов к работе</span> <span class="muted" style="font-weight:400">— желательно исправить: '+yel+'</span>':'<span style="color:var(--ok)">✓ Всё готово к работе</span>'):'<span style="color:var(--err)">✗ Не готово: обязательных проблем — '+red+'</span>';
if(dot) dot.className='dot '+(d.ready?(yel?'warn':'ok'):'err');
document.getElementById('rd-at').textContent='проверено '+d.at;
}).catch(function(){ sum.textContent='Проверка не выполнилась — обновите страницу.'; }); }
document.getElementById('rd-again').onclick=load; load();
})();
</script>
<h3 style="margin-top:18px">Итоговая сводка</h3>
<table>
<tr><td class="muted">PostgreSQL</td><td>{{if .Done.Postgres}}<span style="color:var(--ok)">настроен</span>{{else}}<span class="muted">in-memory</span>{{end}}</td></tr>
<tr><td class="muted">Крипто-провайдер</td><td>{{if .Done.Crypto}}<span style="color:var(--ok)">Валидата Клиент L{{if .ValidataVer}} {{.ValidataVer}}{{end}}</span>{{else}}<span style="color:var(--err)">не настроен</span>{{end}}</td></tr>
<tr><td class="muted">Сертификатов установлено</td><td>{{len .Certs}}</td></tr>
<tr><td class="muted">ИШ НРД</td><td>{{if .Done.NSD}}<span style="color:var(--ok)">{{.Settings.NSD.Profile}}</span>{{else}}<span class="muted">{{if devOn}}mock-режим{{else}}не настроен{{end}}</span>{{end}}</td></tr>
{{if devOn}}<tr><td class="muted">Тестовый прогон</td><td>{{if .Done.TestRun}}<span style="color:var(--ok)">пройден</span>{{else}}<span class="muted">не запускался</span>{{end}}</td></tr>{{end}}
</table>
<div style="margin-top:20px;display:flex;justify-content:space-between"> <div style="margin-top:20px;display:flex;justify-content:space-between">
<a href="/admin/wizard?step=8" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 8</a> <a href="/admin/wizard?step=9" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">← К шагу 9</a>
<a href="/admin/" class="btn" style="text-decoration:none">Перейти к дашборду</a> <a href="/admin/" class="btn" style="text-decoration:none">Перейти к дашборду</a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -469,7 +469,7 @@ details:has(> pre.copyable) { min-width:0; max-width:100%; }
// получает Referer, поэтому говорим путь явно. // получает Referer, поэтому говорим путь явно.
if (!f.querySelector('input[name="return_to"]')) { if (!f.querySelector('input[name="return_to"]')) {
var rt = document.createElement('input'); var rt = document.createElement('input');
rt.type = 'hidden'; rt.name = 'return_to'; rt.value = location.pathname; rt.type = 'hidden'; rt.name = 'return_to'; rt.value = location.pathname + location.search.replace(/[?&](flash|scrollto)=[^&]*/g, '').replace(/^&/, '?');
f.appendChild(rt); f.appendChild(rt);
} }
// Подкладываем секцию (ближайшую карточку) — сервер вернёт ?scrollto=. // Подкладываем секцию (ближайшую карточку) — сервер вернёт ?scrollto=.
@@ -0,0 +1,64 @@
{{/* Оповещения: почта и адресаты — общие для «Настройка → Оповещения» и мастера (данные — SetupData). */}}
{{define "smtpCard"}}
<div class="card" id="sec-smtp">
<h2>Почта (SMTP)</h2>
<p class="muted">Отправка уведомлений по e-mail. Пароль хранится в конфигурации сервиса.</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">
<div><label>SMTP-сервер</label><input type="text" name="host" value="{{.Settings.Alerts.SMTP.Host}}" placeholder="smtp.example.ru" style="margin-top:3px"></div>
<div><label>Порт</label><input type="text" id="smtp-port" name="port" value="{{if .Settings.Alerts.SMTP.Port}}{{.Settings.Alerts.SMTP.Port}}{{else}}587{{end}}" style="margin-top:3px"></div>
<div><label>Шифрование</label>
<select id="smtp-security" name="security" style="margin-top:3px" onchange="smtpSyncPort(this.value)">
<option value="starttls" {{if eq .Settings.Alerts.SMTP.Security "starttls"}}selected{{end}}>STARTTLS (587)</option>
<option value="ssl" {{if eq .Settings.Alerts.SMTP.Security "ssl"}}selected{{end}}>SSL/TLS (465)</option>
<option value="none" {{if eq .Settings.Alerts.SMTP.Security "none"}}selected{{end}}>Без шифрования (25)</option>
</select></div>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-top:12px">
<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>
<button type="submit" formaction="/admin/setup/alerts-smtp-test" class="btn btn-secondary">Отправить проверочное письмо</button>
</div>
</form>
{{if .Settings.Alerts.SMTP.LastMsg}}<p class="muted" style="font-size:12px;margin-top:10px">Последняя проверка: {{.Settings.Alerts.SMTP.LastMsg}}</p>{{end}}
</div>
<script>
// Смена шифрования SMTP авто-подставляет типовой порт; править вручную можно.
function smtpSyncPort(sec){
var p=document.getElementById('smtp-port'); if(!p) return;
var def={starttls:'587', ssl:'465', none:'25'}[sec];
if(def) p.value=def;
}
</script>
{{end}}
{{define "notifyGroupsCard"}}
<div class="card" id="sec-notify-groups">
<h2>Адресаты уведомлений: депозитарий, бэк-офис, мидл-офис</h2>
<p class="muted">Три группы адресов и таблица «какой тип уведомления кому отправлять». Письма идут через SMTP из раздела «Почта». Адрес алертов из раздела «Почта» получает системные оповещения всегда; группы ниже добавляются по маршруту.</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" 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">
<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>
</table>
<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>
</div>
</form>
<p class="muted" style="font-size:12px;margin-top:8px">Требуется настроенный и включённый SMTP в разделе «Почта».</p>
</div>
{{end}}
@@ -0,0 +1,65 @@
{{/* Резервное копирование — общее для «Настройка → Резервное копирование» и мастера (данные — SetupData). */}}
{{define "backupCard"}}
{{$b := .Settings.Backup}}{{$bs := backupStatus}}
<div class="card">
<h2><span class="dot {{if not $b.Enabled}}warn{{else if and $bs $bs.OK}}ok{{else}}err{{end}}"></span>Копии сервера bj на сетевое хранилище</h2>
<p class="muted">Что копируется: база bj (заявки, сделки, кабинет), база и папка шлюза НРД (настройки, сертификаты, полученные от НРД документы), настройки и данные bj. Пароль хранилища bj у себя не хранит — он лежит на сервере в файле, доступном только системе. Восстановить копию можно в мастере настройки (шаг 1, «Восстановить из резервной копии»).</p>
<p style="margin:8px 0">{{if $bs}}Последняя копия: <b>{{backupWhen $bs.At}}</b> — {{if $bs.OK}}<span style="color:var(--ok)">✓ {{$bs.Dir}}, {{$bs.Msg}}</span>{{else}}<span style="color:var(--err)">✗ {{$bs.Msg}}</span>{{end}}{{else}}<span class="muted">Копий ещё не было.</span>{{end}}</p>
<style>#bk-form label:not(.chk){display:block;margin:0}#bk-form label:not(.chk) input,#bk-form label:not(.chk) select{width:100%;box-sizing:border-box;margin-top:4px}</style>
<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>
</div>
<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>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Как часто<select name="every_hours">{{$e := $b.EveryHours}}{{if not $e}}{{$e = 6}}{{end}}{{range $h := intList 1 2 3 4 6 8 12 24}}<option value="{{$h}}"{{if eq $h $e}} selected{{end}}>{{if eq $h 24}}раз в сутки (02:00){{else}}каждые {{$h}} ч{{end}}</option>{{end}}</select></label>
<label>Хранить, дней<input name="keep_days" type="number" min="1" max="365" value="{{if $b.KeepDays}}{{$b.KeepDays}}{{else}}14{{end}}"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" type="submit">Сохранить</button>
<button class="btn" type="button" id="bk-test" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить доступ</button>
<button class="btn" type="button" id="bk-run" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Сделать копию сейчас</button>
<button class="btn" type="button" id="bk-list" style="background:var(--card);color:var(--text);border:1px solid var(--border)">Копии на хранилище</button>
</div>
<div id="bk-out" class="muted" style="font-size:13px"></div>
</form>
<script>
(function(){
var f=document.getElementById('bk-form'), out=document.getElementById('bk-out');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function nice(n){ var m=n.match(/^(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})$/); return m? m[3]+'.'+m[2]+'.'+m[1]+' '+m[4]+':'+m[5] : n; }
function post(url, withForm){
var p=new URLSearchParams();
if(withForm){ ['share','dir','user','password'].forEach(function(n){ var v=f[n].value.trim(); if(v) p.set(n,v); }); }
return fetch(url,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()});
}
document.getElementById('bk-test').onclick=function(){ out.textContent='Проверяю доступ…';
post('/admin/setup/backup/test',true).then(function(d){ out.innerHTML=d.ok?'<span style="color:var(--ok)">✓ '+esc(d.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(d.error)+'</span>'; }); };
document.getElementById('bk-run').onclick=function(){ out.textContent='Запускаю копию…';
post('/admin/setup/backup/run',false).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
out.textContent='Копия делается (обычно до минуты)…'; var t0=Date.now();
var iv=setInterval(function(){ fetch('/admin/setup/backup/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(s){
var b=s.backup; if(b && Date.parse(b.at)>=t0-60000){ clearInterval(iv); out.innerHTML=b.ok?'<span style="color:var(--ok)">✓ копия готова: '+esc(b.dir)+', '+esc(b.msg)+'</span>':'<span style="color:var(--err)">✗ '+esc(b.msg)+'</span>'; }
if(Date.now()-t0>600000) clearInterval(iv); }); },3000); }); };
document.getElementById('bk-list').onclick=function(){ out.textContent='Читаю список…';
post('/admin/setup/backup/list',true).then(function(d){ if(!d.ok){out.innerHTML='<span style="color:var(--err)">✗ '+esc(d.error)+'</span>';return}
if(!d.list||!d.list.length){ out.textContent='Копий на хранилище нет.'; return; }
out.innerHTML='Копий: <b>'+d.list.length+'</b>. «Проверить» скачивает копию и сверяет контрольные суммы — ничего не восстанавливает.'+
'<div style="display:grid;gap:4px;margin-top:6px">'+d.list.slice(0,60).map(function(n){
return '<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap"><code>'+esc(nice(n))+'</code><button type="button" class="btn bk-verify" data-name="'+esc(n)+'" style="padding:2px 10px;font-size:12px;background:var(--card);color:var(--text);border:1px solid var(--border)">Проверить</button><span class="bk-vres" style="font-size:12px"></span></div>';
}).join('')+'</div>';
out.querySelectorAll('.bk-verify').forEach(function(btn){ btn.onclick=function(){
var res=btn.nextElementSibling; res.textContent='скачиваю и проверяю…'; btn.disabled=true;
var p=new URLSearchParams(); ['share','dir','user','password'].forEach(function(k){ var v=f[k].value.trim(); if(v) p.set(k,v); }); p.set('name',btn.dataset.name);
fetch('/admin/setup/backup/verify',{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()}).then(function(v){
btn.disabled=false; res.innerHTML=v.ok?'<span style="color:var(--ok)">✓ '+esc(v.output)+'</span>':'<span style="color:var(--err)">✗ '+esc(v.error)+'</span>'; });
}; }); }); };
})();
</script>
</div>
{{end}}
@@ -80,3 +80,26 @@
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
{{define "mnpCheck"}}
<div class="card" id="mnp-check">
<h2><span class="dot" id="mnp-dot"></span>Проверка модуля неторговых поручений</h2>
<p class="muted" style="margin:0 0 8px">Только чтение: всё, без чего цикл M2M в МНП не работает. Ничего в QUIK не меняет.</p>
<table style="width:100%"><tbody id="mnp-rows"></tbody></table>
<p style="margin-top:8px"><button type="button" class="btn" id="mnp-run">Проверить МНП</button> <span class="muted" id="mnp-at" style="font-size:12px"></span></p>
</div>
<script>
(function(){
var rows=document.getElementById('mnp-rows'), dot=document.getElementById('mnp-dot');
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
var ic={ok:'<span style="color:var(--ok)">✓</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>'};
function run(){ rows.innerHTML='<tr><td class="muted">Проверяю…</td></tr>';
fetch('/admin/setup/quik/mnp-check',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
rows.innerHTML=d.checks.map(function(c){return '<tr><td style="width:22px;vertical-align:top">'+ic[c.state]+'</td><td style="vertical-align:top;padding-right:10px;white-space:nowrap"><b>'+esc(c.title)+'</b></td><td style="vertical-align:top">'+esc(c.detail)+'</td></tr>'}).join('');
dot.className='dot '+d.state; document.getElementById('mnp-at').textContent='проверено '+d.at;
}).catch(function(){ rows.innerHTML='<tr><td>Проверка не выполнилась</td></tr>'; }); }
document.getElementById('mnp-run').onclick=run;
{{if .Settings.Quik.Server}}run();{{end}}
})();
</script>
{{end}}