Первичная настройка, часть 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:
@@ -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/"):
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 в МНП (пишущие операции к боевой БД).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{'&':'&','<':'<','>':'>','"':'"'}[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{'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||||
|
|||||||
@@ -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{'&':'&','<':'<','>':'>','"':'"'}[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{'&':'&','<':'<','>':'>','"':'"'}[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}}
|
||||||
|
|||||||
Reference in New Issue
Block a user