База bj на своём PostgreSQL; «Не заполнено: …» для пустых полей; запись канала ИШ — в фоне, с ходом на странице

- шаг «База bj»: перенос на сервер PostgreSQL организации (проверка подключения, версии, прав, пустой базы;
  pg_dump → psql; сверка числа строк по таблицам; переключение и перезапуск); прежняя база — запасная копия;
  резервные копии: дамп внешней базы по её подключению, восстановление её не трогает
- все формы: пустые видимые обязательные поля называются по имени и подсвечиваются; DEPO — и на сервере
- «Обновить/Создать канал»: в фоне bj, ход по этапам с таймером (и после перезагрузки страницы), переходы
  заблокированы до конца, в конце проверка, что шлюз загрузил канал; состояние ИШ во время записи — без
  очереди к root-обработчику (страница больше не висит на «Проверяю…»)

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-07 00:00:35 +03:00
parent 3a9bc5d7fa
commit 4adc1398f6
16 changed files with 832 additions and 50 deletions
+1
View File
@@ -60,6 +60,7 @@ var templateFuncs = template.FuncMap{
"backupStatus": backupStatus,
"backupWhen": backupWhen,
"smbWin": smbWin,
"pgLocal": pgDSNLocal,
"whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} },
"weekdayNames": func() []string { return weekdayShort },
"depoAccPrefix": depoAccPrefix,
+19 -17
View File
@@ -139,6 +139,15 @@ var igateVersionRe = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
// состояние ИШ и каналов + что выбрано в bj
mux.HandleFunc("/admin/setup/igate/status", func(w http.ResponseWriter, r *http.Request) {
// пока записывается канал, root-обработчик занят им (до двух минут) и запрос состояния ждал бы в очереди —
// страница после перезагрузки висела на «Проверяю…»; отдаём последнее известное состояние
if v := igateContourJob.view(); v.Running {
if last := igateLastStatus.get(); last != nil {
last["busy"] = "идёт запись канала " + strings.ToUpper(v.Contour)
jsonOut(w, last)
return
}
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
res, err := runKeymediaOp(ctx, "igate-status", nil)
@@ -146,6 +155,7 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
return
}
defer igateLastStatus.set(res)
s := rc.Snapshot()
res["active_contour"] = ""
if s.NSD.Profile != "" {
@@ -240,24 +250,16 @@ func registerIgateAdmin(mux *http.ServeMux, rc *RuntimeConfig) {
jsonOut(w, map[string]any{"ok": false, "error": "сначала укажите депозитарный код организации (мастер, раздел «Организация»)"})
return
}
extra, _ := json.Marshal(map[string]string{"contour": contour, "crypto": crypto, "deponent": dep})
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": string(extra)})
if err == nil && res["ok"] == true && rc.Snapshot().NSD.Profile == "" {
// контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий
n := rc.Snapshot().NSD
n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL)
if rc.UpdateNSD(n) == nil {
res["output"] = asString(res["output"]) + ". bj работает с этим контуром и перезапускается — обновите страницу через 15 секунд"
go func() {
time.Sleep(2 * time.Second)
_, _ = restartUnit(context.Background(), "bj-server")
}()
}
if !igateContourJob.start(contour) {
jsonOut(w, map[string]any{"ok": true, "running": true}) // уже идёт — страница покажет ход
return
}
refreshCertStore(rc)
jsonOut(w, opResult(res, err))
go igateContourRun(rc, contour, crypto, dep)
jsonOut(w, map[string]any{"ok": true, "started": true})
})
// ход записи канала: страница показывает его и после перезагрузки
mux.HandleFunc("/admin/setup/igate/contour-status", func(w http.ResponseWriter, r *http.Request) {
jsonOut(w, igateContourJob.view())
})
// переключить bj на контур: профиль отправки, база DEPO контура; bj перезапускается
mux.HandleFunc("/admin/setup/igate/switch", func(w http.ResponseWriter, r *http.Request) {
+153
View File
@@ -0,0 +1,153 @@
package lkgateway
// Запись канала контура НРД в ИШ (06.10.2026). Root-обработчик пишет канал в базу ИШ и перезапускает шлюз — до
// двух минут. Раньше это делал сам запрос страницы: перезагрузка страницы прерывала ожидание, а запрос
// состояния ИШ вставал в очередь за записью канала — страница висела на «Проверяю…» без объяснений, и человек
// мог уйти «Далее» посреди перезапуска. Теперь запись идёт в фоне bj, страница показывает её ход (и после
// перезагрузки), а в конце bj проверяет, что шлюз действительно загрузил канал.
import (
"context"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
)
// igateContourView — ход записи канала для страницы.
type igateContourView struct {
Running bool `json:"running"`
Contour string `json:"contour,omitempty"`
Stage string `json:"stage,omitempty"` // write | engine | channel | done
Started int64 `json:"started,omitempty"`
Done bool `json:"done,omitempty"`
OK bool `json:"ok"`
Msg string `json:"msg,omitempty"`
At int64 `json:"at,omitempty"`
}
type igateContourState struct {
sync.Mutex
v igateContourView
}
var igateContourJob igateContourState
func (j *igateContourState) start(contour string) bool {
j.Lock()
defer j.Unlock()
if j.v.Running {
return false
}
j.v = igateContourView{Running: true, Contour: contour, Stage: "write", Started: time.Now().Unix(), OK: true}
return true
}
func (j *igateContourState) stage(st string) {
j.Lock()
j.v.Stage = st
j.Unlock()
}
func (j *igateContourState) finish(ok bool, msg string) {
j.Lock()
j.v.Running, j.v.Done, j.v.OK, j.v.Msg, j.v.Stage, j.v.At = false, true, ok, msg, "done", time.Now().Unix()
j.Unlock()
}
func (j *igateContourState) view() igateContourView {
j.Lock()
defer j.Unlock()
v := j.v
if v.Done && time.Since(time.Unix(v.At, 0)) > 15*time.Minute {
return igateContourView{OK: true} // итог давний — не показываем
}
return v
}
// igateLastStatus — последнее состояние ИШ от root-обработчика (отдаётся, пока он занят записью канала).
var igateLastStatus lastStatus
type lastStatus struct {
sync.Mutex
m map[string]any
}
func (l *lastStatus) set(m map[string]any) {
l.Lock()
l.m = m
l.Unlock()
}
func (l *lastStatus) get() map[string]any {
l.Lock()
defer l.Unlock()
if l.m == nil {
return nil
}
out := map[string]any{}
for k, v := range l.m {
out[k] = v
}
return out
}
// igateContourRun — запись канала контура в ИШ и проверка, что шлюз его загрузил (в фоне, без запроса страницы).
func igateContourRun(rc *RuntimeConfig, contour, crypto, dep string) {
extra := `{"contour":"` + contour + `","crypto":"` + crypto + `","deponent":"` + dep + `"}`
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
go func() { // этап «перезапуск ИШ» — root-обработчик пишет канал за секунды и дальше ждёт движок
time.Sleep(4 * time.Second)
if v := igateContourJob.view(); v.Running && v.Stage == "write" {
igateContourJob.stage("engine")
}
}()
res, err := runKeymediaOp(ctx, "igate-contour", map[string]string{"extra": extra})
if err != nil || res["ok"] != true {
msg := errStr(err)
if e, ok := res["error"].(string); ok && e != "" {
msg = e
}
igateContourJob.finish(false, msg)
return
}
out := asString(res["output"])
code := strings.ToUpper(contour) + dep
if prof, err := nsdadapter.LookupProfile(contour + "-" + crypto); err == nil {
code = prof.Channel + dep
}
enabled := strings.Contains(out, "включён")
if enabled { // канал с ключом: шлюз должен его загрузить — иначе любой обмен даёт HTTP 500
igateContourJob.stage("channel")
base := pickStr(rc.Snapshot().NSD.IGWBaseURL, igateDefaultURL)
loaded := false
for i := 0; i < 20 && !loaded; i++ {
if found, en, err := igateChannelState(ctx, base, code); err == nil && found && en {
loaded = true
break
}
time.Sleep(3 * time.Second)
}
if !loaded {
igateContourJob.finish(false, "канал "+code+" записан, но шлюз его не загрузил — журнал ИШ: /var/lib/igate/.local/share/NSD Integration Gateway/logs-svc/log.txt")
return
}
out += "; шлюз загрузил канал " + code
}
if rc.Snapshot().NSD.Profile == "" {
// контур в bj ещё не выбран (новая установка) — первый настроенный канал и есть рабочий
n := rc.Snapshot().NSD
n.Profile, n.IGWBaseURL = contour+"-"+crypto, pickStr(n.IGWBaseURL, igateDefaultURL)
if rc.UpdateNSD(n) == nil {
out += ". bj работает с этим контуром и перезапускается — страница обновится сама"
go func() {
time.Sleep(3 * time.Second)
_, _ = restartUnit(context.Background(), "bj-server")
}()
}
}
refreshCertStore(rc)
igateContourJob.finish(true, out)
}
+353
View File
@@ -0,0 +1,353 @@
package lkgateway
// База bj на своём сервере PostgreSQL организации (06.10.2026). Установщик кладёт базу bj на этот же сервер —
// так и остаётся по умолчанию. Если у организации свой сервер PostgreSQL (с резервным копированием и
// администратором баз), базу можно держать там: шаг «База bj» проверяет подключение и права, переносит данные
// (pg_dump этой базы → psql в пустую базу на том сервере), сверяет число строк в каждой таблице, переключает
// bj и перезапускает его. Прежняя база на этом сервере не удаляется — остаётся запасной копией.
// Только PostgreSQL: другие СУБД bj для своей базы не поддерживает.
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// pgTarget — сервер PostgreSQL организации для базы bj (из формы).
type pgTarget struct{ Host, Port, DB, User, Password, SSL string }
func pgTargetFromForm(r *http.Request) (pgTarget, string) {
t := pgTarget{
Host: strings.TrimSpace(r.FormValue("pg_host")), Port: strings.TrimSpace(r.FormValue("pg_port")),
DB: strings.TrimSpace(r.FormValue("pg_db")), User: strings.TrimSpace(r.FormValue("pg_user")),
Password: r.FormValue("pg_password"), SSL: r.FormValue("pg_ssl"),
}
if t.Port == "" {
t.Port = "5432"
}
switch t.SSL {
case "disable", "require", "verify-full":
default:
t.SSL = "prefer"
}
switch {
case t.Host == "" || strings.ContainsAny(t.Host, "/ @"):
return t, "укажите адрес сервера PostgreSQL"
case pgLocalHost(t.Host):
return t, "это адрес этого же сервера — база bj и так здесь; укажите сервер PostgreSQL организации"
case t.DB == "" || t.User == "":
return t, "укажите базу и пользователя"
}
if p, err := strconv.Atoi(t.Port); err != nil || p < 1 || p > 65535 {
return t, "порт — число от 1 до 65535"
}
return t, ""
}
// dsn — строка подключения; withPass=false — без пароля (для pg_dump/psql пароль идёт через PGPASSWORD,
// чтобы не светиться в списке процессов).
func (t pgTarget) dsn(withPass bool) string {
u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(t.Host, t.Port), Path: "/" + t.DB}
if withPass && t.Password != "" {
u.User = url.UserPassword(t.User, t.Password)
} else {
u.User = url.User(t.User)
}
u.RawQuery = "sslmode=" + t.SSL
return u.String()
}
func pgLocalHost(h string) bool {
h = strings.Trim(strings.ToLower(h), "[]")
return h == "" || h == "localhost" || h == "127.0.0.1" || h == "::1" || strings.HasPrefix(h, "/")
}
// pgDSNLocal — база по этой строке подключения на этом же сервере.
func pgDSNLocal(dsn string) bool {
u, err := url.Parse(strings.TrimSpace(dsn))
return err != nil || pgLocalHost(u.Hostname())
}
// pgSplitDSN — строка подключения без пароля и пароль отдельно (для внешних программ).
func pgSplitDSN(dsn string) (string, string) {
u, err := url.Parse(strings.TrimSpace(dsn))
if err != nil || u.User == nil {
return dsn, ""
}
pw, _ := u.User.Password()
u.User = url.User(u.User.Username())
return u.String(), pw
}
// pgInfo — что известно о базе: версия сервера, права, таблицы.
type pgInfo struct {
Version string
Major int
CanCreate bool // CREATE в базе: создать схемы bj
PublicOK bool // CREATE в схеме public (с PostgreSQL 15 по умолчанию есть только у владельца базы)
Tables []string
Rows map[string]int64 // для сверки после переноса
}
func pgInspect(ctx context.Context, dsn string, counts bool) (pgInfo, error) {
info := pgInfo{Rows: map[string]int64{}}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
conn, err := pgx.Connect(cctx, dsn)
if err != nil {
return info, err
}
defer conn.Close(context.Background())
var num int
if err := conn.QueryRow(cctx, "SELECT current_setting('server_version'), current_setting('server_version_num')::int, has_database_privilege(current_user, current_database(), 'CREATE'), COALESCE(has_schema_privilege(current_user, 'public', 'CREATE'), false)").
Scan(&info.Version, &num, &info.CanCreate, &info.PublicOK); err != nil {
return info, err
}
info.Major = num / 10000
rows, err := conn.Query(cctx, "SELECT quote_ident(table_schema)||'.'||quote_ident(table_name) FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') AND table_schema NOT LIKE 'pg_toast%' ORDER BY 1")
if err != nil {
return info, err
}
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
info.Tables = append(info.Tables, n)
}
}
rows.Close()
if counts {
for _, t := range info.Tables {
var c int64
if err := conn.QueryRow(ctx, "SELECT count(*) FROM "+t).Scan(&c); err != nil {
return info, fmt.Errorf("подсчёт строк %s: %w", t, err)
}
info.Rows[t] = c
}
}
return info, nil
}
// pgPublicUsed — в базе bj есть таблицы в схеме public (тогда на новом сервере нужно право создавать в ней).
func pgPublicUsed(tables []string) bool {
for _, t := range tables {
if strings.HasPrefix(t, "public.") {
return true
}
}
return false
}
// pgCheckTarget — годится ли сервер организации: подключение, версия не старше нашей, права, пустая база.
func pgCheckTarget(ctx context.Context, src pgInfo, t pgTarget) (pgInfo, string) {
dst, err := pgInspect(ctx, t.dsn(true), false)
if err != nil {
return dst, "не удалось подключиться к " + t.Host + ":" + t.Port + ", база " + t.DB + ": " + pgErrRu(err)
}
switch {
case src.Major > 0 && dst.Major < src.Major:
return dst, fmt.Sprintf("на сервере организации PostgreSQL %s, а база bj сейчас на %s — нужен PostgreSQL %d или новее", dst.Version, src.Version, src.Major)
case !dst.CanCreate:
return dst, "у пользователя " + t.User + " нет права создавать объекты в базе " + t.DB + " (GRANT CREATE ON DATABASE " + t.DB + " TO " + t.User + ") — проще всего сделать его владельцем базы"
case pgPublicUsed(src.Tables) && !dst.PublicOK:
return dst, "у пользователя " + t.User + " нет права создавать таблицы в схеме public базы " + t.DB + " (с PostgreSQL 15 оно есть только у владельца базы) — сделайте его владельцем базы: ALTER DATABASE " + t.DB + " OWNER TO " + t.User
case len(dst.Tables) > 0:
return dst, fmt.Sprintf("в базе %s уже есть таблицы (%d, например %s) — для bj нужна пустая база", t.DB, len(dst.Tables), dst.Tables[0])
}
return dst, ""
}
func pgErrRu(err error) string {
s := err.Error()
switch {
case strings.Contains(s, "password authentication failed"):
return "неверный пользователь или пароль"
case strings.Contains(s, "does not exist") && strings.Contains(s, "database"):
return "такой базы нет — создайте пустую базу"
case strings.Contains(s, "no pg_hba.conf entry"):
return "сервер не пускает с адреса bj — администратору баз нужно разрешить подключение в pg_hba.conf"
case strings.Contains(s, "connection refused"), strings.Contains(s, "i/o timeout"), strings.Contains(s, "no route to host"):
return "сервер не отвечает на этом адресе и порту (" + s + ")"
}
return s
}
// pgCopy — данные базы bj на сервер организации: pg_dump (простой SQL без владельцев и прав) → psql.
func pgCopy(ctx context.Context, srcDSN string, t pgTarget) error {
src, srcPass := pgSplitDSN(srcDSN)
dump := exec.CommandContext(ctx, "pg_dump", "--no-owner", "--no-privileges", "--format=plain", "--dbname="+src)
dump.Env = append(os.Environ(), "PGPASSWORD="+srcPass)
load := exec.CommandContext(ctx, "psql", "--quiet", "--no-psqlrc", "-v", "ON_ERROR_STOP=1", "--dbname="+t.dsn(false))
load.Env = append(os.Environ(), "PGPASSWORD="+t.Password) // без PGOPTIONS: lc_messages — только суперпользователю
pipe, err := dump.StdoutPipe()
if err != nil {
return err
}
load.Stdin = pipe
var dumpErr, loadErr strings.Builder
dump.Stderr, load.Stderr, load.Stdout = &dumpErr, &loadErr, io.Discard
if err := load.Start(); err != nil {
return fmt.Errorf("psql: %w", err)
}
if err := dump.Start(); err != nil {
_ = load.Process.Kill()
return fmt.Errorf("pg_dump: %w", err)
}
de := dump.Wait()
le := load.Wait()
if de != nil {
return fmt.Errorf("pg_dump: %v %s", de, lastLine(dumpErr.String()))
}
if le != nil {
return fmt.Errorf("загрузка на сервер организации: %v %s", le, lastLine(loadErr.String()))
}
return nil
}
func lastLine(s string) string {
sc := bufio.NewScanner(strings.NewReader(s))
last := ""
for sc.Scan() {
if l := strings.TrimSpace(sc.Text()); l != "" {
last = l
}
}
return last
}
// pgCompare — расхождения числа строк (пусто — всё сошлось).
func pgCompare(src, dst pgInfo) []string {
var bad []string
for _, t := range src.Tables {
if d, ok := dst.Rows[t]; !ok {
bad = append(bad, t+": нет на новом сервере")
} else if d != src.Rows[t] {
bad = append(bad, fmt.Sprintf("%s: было %d строк, стало %d", t, src.Rows[t], d))
}
}
sort.Strings(bad)
return bad
}
func (h *setupHandlers) registerPGMove(mux *http.ServeMux) {
mux.HandleFunc("/admin/setup/postgres/move", h.pgMove)
}
// pgMove — POST /admin/setup/postgres/move: check=1 — только проверить сервер организации; иначе перенести
// и переключиться. Ход — строками JSON (как подготовка DEPO): этапы и итог.
func (h *setupHandlers) pgMove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/x-ndjson; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no")
fl, _ := w.(http.Flusher)
enc := json.NewEncoder(w)
send := func(v any) {
_ = enc.Encode(v)
if fl != nil {
fl.Flush()
}
}
step := func(key, state, text string) { send(map[string]string{"step": key, "state": state, "text": text}) }
done := func(ok bool, msg string) {
send(map[string]any{"done": true, "ok": ok, "msg": msg, "go": "/admin/wizard?step=2&flash=" + url.QueryEscape(msg)})
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Minute)
defer cancel()
cur := h.rc.Snapshot().Postgres.DSN
t, bad := pgTargetFromForm(r)
if bad != "" {
done(false, bad)
return
}
if !pgDSNLocal(cur) {
done(false, "база bj уже не на этом сервере — перенос делается с базы на этом сервере")
return
}
step("src", "run", "База bj на этом сервере")
src, err := pgInspect(ctx, cur, false)
if err != nil {
step("src", "err", pgErrRu(err))
done(false, "база bj на этом сервере недоступна: "+pgErrRu(err))
return
}
step("src", "ok", fmt.Sprintf("PostgreSQL %s, таблиц: %d", src.Version, len(src.Tables)))
step("dst", "run", "Подключение к серверу организации "+t.Host+":"+t.Port+", база "+t.DB)
dst, why := pgCheckTarget(ctx, src, t)
if why != "" {
step("dst", "err", why)
done(false, why)
return
}
step("dst", "ok", "PostgreSQL "+dst.Version+", база пустая, права есть")
if r.FormValue("check") != "" {
done(true, "Сервер организации подходит: PostgreSQL "+dst.Version+", база "+t.DB+" пустая, права есть. Можно переносить.")
return
}
step("copy", "run", "Копирование данных bj (pg_dump → psql)")
start := time.Now()
if err := pgCopy(ctx, cur, t); err != nil {
step("copy", "err", err.Error())
done(false, "данные не перенесены: "+err.Error()+". bj продолжает работать с базой на этом сервере; на сервере организации очистите базу "+t.DB+" и повторите")
return
}
step("copy", "ok", "за "+humanDur(time.Since(start)))
step("verify", "run", "Сверка: число строк в каждой таблице")
srcN, err1 := pgInspect(ctx, cur, true)
dstN, err2 := pgInspect(ctx, t.dsn(true), true)
if err1 != nil || err2 != nil {
e := err1
if e == nil {
e = err2
}
step("verify", "err", e.Error())
done(false, "сверка не выполнена: "+e.Error()+". bj продолжает работать с базой на этом сервере")
return
}
if diff := pgCompare(srcN, dstN); len(diff) > 0 {
// пока шло копирование, bj мог дописать строки — это не повод переключаться на неполную базу
step("verify", "err", strings.Join(diff, "; "))
done(false, "после копирования данные не совпали ("+strings.Join(diff, "; ")+"). bj продолжает работать с базой на этом сервере; очистите базу "+t.DB+" на сервере организации и повторите в спокойное время")
return
}
var total int64
for _, n := range srcN.Rows {
total += n
}
step("verify", "ok", fmt.Sprintf("таблиц %d, строк %d — совпадает", len(srcN.Tables), total))
step("switch", "run", "Переключение bj на сервер организации и перезапуск")
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: t.dsn(true), Local: cur}); err != nil {
step("switch", "err", err.Error())
done(false, "настройки не сохранены: "+err.Error())
return
}
step("switch", "ok", "bj перезапускается — через полминуты он работает с базой на "+t.Host)
log.Printf("postgres: база bj перенесена на %s:%s/%s (%d таблиц, %d строк); прежняя база на этом сервере сохранена", t.Host, t.Port, t.DB, len(srcN.Tables), total)
done(true, "База bj перенесена на сервер организации "+t.Host+", база "+t.DB+". Прежняя база на этом сервере сохранена как запасная копия.")
go func() { // перезапуск после ответа: страница успевает показать итог
time.Sleep(2 * time.Second)
rctx, rcancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer rcancel()
if _, err := restartUnit(rctx, "bj-server"); err != nil {
log.Printf("postgres: перезапуск после переноса: %v", err)
}
}()
}
+63
View File
@@ -0,0 +1,63 @@
package lkgateway
import (
"context"
"net/url"
"os"
"testing"
)
// Перенос базы bj на живом PostgreSQL: BJ_PGMOVE_SRC — строка подключения исходной базы, BJ_PGMOVE_DST — пустой
// целевой (для проверки допускается этот же сервер). Без переменных — пропуск.
func TestPGMoveLive(t *testing.T) {
srcDSN, dstDSN := os.Getenv("BJ_PGMOVE_SRC"), os.Getenv("BJ_PGMOVE_DST")
if srcDSN == "" || dstDSN == "" {
t.Skip("нет BJ_PGMOVE_SRC/BJ_PGMOVE_DST")
}
u, _ := url.Parse(dstDSN)
pw, _ := u.User.Password()
tg := pgTarget{Host: u.Hostname(), Port: u.Port(), DB: u.Path[1:], User: u.User.Username(), Password: pw, SSL: u.Query().Get("sslmode")}
ctx := context.Background()
src, err := pgInspect(ctx, srcDSN, false)
if err != nil {
t.Fatal(err)
}
if _, why := pgCheckTarget(ctx, src, tg); why != "" {
t.Fatal(why)
}
if err := pgCopy(ctx, srcDSN, tg); err != nil {
t.Fatal(err)
}
s, _ := pgInspect(ctx, srcDSN, true)
d, err := pgInspect(ctx, tg.dsn(true), true)
if err != nil {
t.Fatal(err)
}
if diff := pgCompare(s, d); len(diff) > 0 {
t.Fatalf("расхождения: %v", diff)
}
var n int64
for _, c := range s.Rows {
n += c
}
t.Logf("перенесено: таблиц %d, строк %d (PostgreSQL %s → %s)", len(s.Tables), n, s.Version, d.Version)
if _, why := pgCheckTarget(ctx, src, tg); why == "" {
t.Fatal("после переноса база не пустая — повторная проверка должна отказать")
}
}
func TestPGTargetForm(t *testing.T) {
tg := pgTarget{Host: "db.local", Port: "5432", DB: "bj", User: "bj", Password: "p@ss:w/rd", SSL: "require"}
if got := tg.dsn(true); got != "postgres://bj:p%40ss%3Aw%2Frd@db.local:5432/bj?sslmode=require" {
t.Errorf("dsn: %s", got)
}
if s, p := pgSplitDSN(tg.dsn(true)); p != "p@ss:w/rd" || s != "postgres://bj@db.local:5432/bj?sslmode=require" {
t.Errorf("split: %s / %s", s, p)
}
if !pgDSNLocal("postgres://bj:x@127.0.0.1:5432/bj") || pgDSNLocal("postgres://bj:x@10.0.0.5:5432/bj") {
t.Error("pgDSNLocal")
}
if d := pgCompare(pgInfo{Tables: []string{"a.t"}, Rows: map[string]int64{"a.t": 3}}, pgInfo{Rows: map[string]int64{"a.t": 2}}); len(d) != 1 {
t.Errorf("сверка: %v", d)
}
}
+3
View File
@@ -175,6 +175,9 @@ type FetchedCACert struct {
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
type PostgresSettings struct {
DSN string `json:"dsn"`
// Local — прежняя база bj на этом сервере после переноса на сервер организации (pgmove.go): не удаляется,
// остаётся запасной копией.
Local string `json:"local,omitempty"`
}
// FancyMSSQLSettings — подключение к MSSQL-серверу клиента: канал обмена с
+1
View File
@@ -57,6 +57,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
})
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
h.registerPGMove(mux)
h.registerFancy(mux) // вкладка «Обмен Fancy (MSSQL)»
h.registerQuik(mux) // вкладка «QUIK МНП» (блокировка бумаг)
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
@@ -218,9 +218,9 @@
<section class="dq-s">
<h3 class="dq-h">Где база DEPO</h3>
<div class="dq-grid dq-srv">
<label>Адрес сервера<input type="text" name="server" value="{{.Settings.Fancy.Server}}" placeholder="IP-адрес или имя\экземпляр" required autofocus spellcheck="false"></label>
<label>Адрес сервера<input type="text" name="server" data-need="адрес сервера DEPO" value="{{.Settings.Fancy.Server}}" placeholder="IP-адрес или имя\экземпляр" required autofocus spellcheck="false"></label>
<label>Порт<input type="text" name="port" value="{{if .Settings.Fancy.Port}}{{.Settings.Fancy.Port}}{{else if $pg}}5432{{else}}1433{{end}}" inputmode="numeric"></label>
<label>Имя базы<input type="text" name="depo_database" value="{{.Settings.Fancy.DepoDatabase}}" placeholder="как называется на сервере" required pattern="[A-Za-z][A-Za-z0-9_]*" title="латинские буквы, цифры и _, начинается с буквы" spellcheck="false"></label>
<label>Имя базы<input type="text" name="depo_database" data-need="имя базы DEPO" value="{{.Settings.Fancy.DepoDatabase}}" placeholder="как называется на сервере" required pattern="[A-Za-z][A-Za-z0-9_]*" title="латинские буквы, цифры и _, начинается с буквы" spellcheck="false"></label>
</div>
<p class="dq-note">Точное имя базы учёта DEPO — bj будет работать только с ней. Перед подготовкой bj проверит, что в ней есть учётные таблицы DEPO, и в чужую базу ничего не запишет.</p>
</section>
@@ -249,8 +249,8 @@
<div class="dq-panel dq-box" id="wiz-depo-auto">
<div class="dq-box-h">Администратор SQL <span>— нужен один раз, чтобы подготовить базу; bj его не сохраняет</span></div>
<div class="dq-grid dq-2">
<label>Логин<input type="text" name="admin_user" autocomplete="off" spellcheck="false" placeholder="{{if $pg}}postgres{{else}}sa{{end}}"></label>
<label>Пароль<input type="password" name="admin_password" autocomplete="new-password"></label>
<label>Логин<input type="text" name="admin_user" data-need="логин администратора SQL" autocomplete="off" spellcheck="false" placeholder="{{if $pg}}postgres{{else}}sa{{end}}"></label>
<label>Пароль<input type="password" name="admin_password" data-need="пароль администратора SQL" autocomplete="new-password"></label>
</div>
</div>
@@ -258,8 +258,8 @@
<div class="dq-panel dq-box dq-box-bj" id="wiz-depo-login">
<div class="dq-box-h">Логин bj <span>— под ним bj будет работать с базой DEPO</span></div>
<div class="dq-grid dq-2">
<label>Логин<input type="text" name="bj_user" value="{{.DepoUser}}" autocomplete="off" spellcheck="false"></label>
<label><span class="dq-lbl-row">Пароль <button type="button" class="dq-link" id="wiz-depo-gen">↻ другой пароль</button></span><input type="text" name="bj_password" value="{{.DepoPass}}" autocomplete="off" spellcheck="false" class="dq-mono"></label>
<label>Логин<input type="text" name="bj_user" data-need="логин bj" value="{{.DepoUser}}" autocomplete="off" spellcheck="false"></label>
<label><span class="dq-lbl-row">Пароль <button type="button" class="dq-link" id="wiz-depo-gen">↻ другой пароль</button></span><input type="text" name="bj_password" data-need="пароль bj" value="{{.DepoPass}}" autocomplete="off" spellcheck="false" class="dq-mono"></label>
</div>
<p class="dq-note">bj придумал их сам. Можно заменить своими — <b>bj сохранит и будет использовать ровно то, что здесь написано</b>: подготовка создаст на сервере этот логин с этим паролем<span id="wiz-depo-login-script"> (в скрипте — они же)</span>.</p>
<p class="dq-warn">Если логин с таким именем на сервере уже есть, ему будет поставлен этот пароль — программа, которая им пользуется, перестанет входить. Тогда впишите другое имя.</p>
@@ -286,8 +286,8 @@
<div class="dq-panel" id="wiz-depo-existing">
<div class="dq-grid dq-2">
<label>Логин bj<input type="text" name="user" value="{{.Settings.Fancy.User}}" autocomplete="off" spellcheck="false"></label>
<label>Пароль<input type="password" name="password" autocomplete="new-password" placeholder="{{if .Settings.Fancy.Password}}пусто — прежний{{end}}"></label>
<label>Логин bj<input type="text" name="user" data-need="логин bj" value="{{.Settings.Fancy.User}}" autocomplete="off" spellcheck="false"></label>
<label>Пароль<input type="password" name="password" {{if not .Settings.Fancy.Password}}data-need="пароль bj"{{end}} autocomplete="new-password" placeholder="{{if .Settings.Fancy.Password}}пусто — прежний{{end}}"></label>
</div>
<button type="submit" class="btn dq-go">Проверить подключение</button>
</div>
@@ -408,7 +408,52 @@
<div class="card">
<h2><span class="dot {{if .Done.Postgres}}ok{{else}}err{{end}}"></span>База данных bj (PostgreSQL)</h2>
<p>База самого bj: журнал заявок и поручений, история обмена с НРД и DEPO, пользователи.</p>
{{if .Done.Postgres}}<div class="wiz-ok"><i class="wiz-ic"></i><span>База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно.</span></div>{{end}}
{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}<div class="wiz-ok"><i class="wiz-ic"></i><span>База готова{{if .PGSummary}}: {{.PGSummary}}{{end}}. Её создал установщик — ничего делать не нужно.</span></div>
<details style="margin-top:14px" {{if .Flash}}open{{end}}><summary style="cursor:pointer;color:var(--accent)">Хранить базу bj на своём сервере PostgreSQL (необязательно)</summary>
<p class="muted" style="font-size:13.5px;margin:10px 0 0">Если у организации есть свой сервер PostgreSQL — с резервным копированием и администратором баз, — базу bj можно держать на нём. bj перенесёт данные, сверит число строк в каждой таблице и переключится сам; база на этом сервере останется запасной копией. Нужно: пустая база на сервере организации, пользователь — её владелец, доступ с адреса bj ({{serverIP}}) к порту PostgreSQL. Только PostgreSQL той же версии, что здесь, или новее.</p>
<form method="post" action="/admin/setup/postgres/move" id="pg-form" class="pg-form">
<label class="pg-wide">Сервер PostgreSQL<input type="text" name="pg_host" placeholder="db.example.local" required autocomplete="off"></label>
<label>Порт<input type="text" name="pg_port" value="5432" inputmode="numeric" autocomplete="off"></label>
<label>База<input type="text" name="pg_db" value="bj" required autocomplete="off"></label>
<label>Пользователь<input type="text" name="pg_user" value="bj" required autocomplete="off"></label>
<label>Пароль<input type="password" name="pg_password" autocomplete="new-password"></label>
<label>Шифрование<select name="pg_ssl"><option value="prefer">если сервер умеет (prefer)</option><option value="require">обязательно (require)</option><option value="verify-full">обязательно, с проверкой сертификата</option><option value="disable">без шифрования</option></select></label>
<div class="pg-wide" style="display:flex;gap:8px;flex-wrap:wrap">
<button type="submit" name="check" value="1" class="btn btn-secondary pg-go">Проверить подключение</button>
<button type="submit" class="btn pg-go" data-confirm="Перенести базу bj на сервер организации и переключиться? bj перезапустится; на время переноса (обычно до минуты) лучше не проводить переводы.">Перенести базу и переключиться</button>
</div>
<div class="dq-run pg-wide" id="pg-run" hidden aria-live="polite"><ol class="dq-steps" id="pg-steps"></ol><div class="dq-run-msg" id="pg-msg" hidden></div></div>
</form>
<style>.pg-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;max-width:720px;margin-top:12px}.pg-form label{display:block;margin:0}.pg-form label input,.pg-form label select{width:100%;box-sizing:border-box;margin-top:4px}.pg-wide{grid-column:1/-1}@media (max-width:600px){.pg-form{grid-template-columns:minmax(0,1fr)}}</style>
<script>(function(){
var f=document.getElementById('pg-form'); if(!f||!window.fetch||!window.TextDecoder) return;
var run=document.getElementById('pg-run'), list=document.getElementById('pg-steps'), msg=document.getElementById('pg-msg');
function li(k){ return list.querySelector('[data-k="'+k+'"]'); }
function mark(el,st,text){ if(!el) return; el.className='dq-st is-'+st; if(text){ var d=el.querySelector('.dq-st-d'); if(!d){ d=document.createElement('div'); d.className='dq-st-d'; el.querySelector('.dq-st-t').appendChild(d);} d.textContent=text; } }
function onStep(x){ if(x.state==='run'){ var e=li(x.step); if(!e){ e=document.createElement('li'); e.dataset.k=x.step; e.innerHTML='<span class="dq-st-ic"></span><div class="dq-st-t"><span></span></div>'; list.appendChild(e);} e.querySelector('.dq-st-t > span').textContent=x.text; mark(e,'run'); } else mark(li(x.step),x.state,x.text); }
f.addEventListener('submit',function(ev){
var b=ev.submitter; if(!b) return; ev.preventDefault();
if(b.dataset.confirm && !confirm(b.dataset.confirm)) return;
var btns=[].slice.call(f.querySelectorAll('.pg-go')); btns.forEach(function(x){x.disabled=true;});
list.innerHTML=''; msg.hidden=true; run.hidden=false;
var fd=new FormData(f); if(b.name) fd.set(b.name,b.value);
var final=null;
fetch(f.action,{method:'POST',body:new URLSearchParams(fd),headers:{'Content-Type':'application/x-www-form-urlencoded'},cache:'no-store'}).then(function(r){
if(!r.ok||!r.body) throw new Error('bad');
var rd=r.body.getReader(), dec=new TextDecoder(), buf='';
function line(t){ if(!t.trim()) return; var x=JSON.parse(t); if(x.done) final=x; else onStep(x); }
function pump(){ return rd.read().then(function(c){ if(c.done){ line(buf); return; } buf+=dec.decode(c.value,{stream:true}); var i; while((i=buf.indexOf('\n'))>=0){ line(buf.slice(0,i)); buf=buf.slice(i+1);} return pump(); }); }
return pump();
}).then(function(){ fin(final,b); }, function(){ fin(null,b); });
function fin(x,b){
msg.textContent=x&&x.msg?x.msg:'Связь с bj прервалась — обновите страницу.'; msg.hidden=false;
if(x&&x.ok&&!b.name){ msg.textContent+=' Страница обновится, когда bj перезапустится.'; setTimeout(function(){ location.href='/admin/wizard?step=2'; },20000); return; }
btns.forEach(function(x){x.disabled=false;});
}
});
})();</script>
</details>
{{else if .Done.Postgres}}<div class="wiz-ok"><i class="wiz-ic"></i><span>База bj — на сервере организации{{if .PGSummary}}: {{.PGSummary}}{{end}}.{{if .Settings.Postgres.Local}} Прежняя база на этом сервере сохранена как запасная копия.{{end}}</span></div>{{end}}
{{if not .Done.Postgres}}<div class="help-block">
<strong>Обычно базу создаёт установщик</strong> — если вы ставили bj с образа или командой установки, этот шаг уже выполнен. Если база bj на отдельном сервере PostgreSQL — введите строку подключения ниже{{if devOn}}, или поднимите локальную базу автоматически (режим разработки){{end}}.
@@ -425,15 +470,15 @@
</div>
{{end}}
<details style="margin-top:14px" {{if not .Settings.Postgres.DSN}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent)">{{if .Settings.Postgres.DSN}}Подключить другую базу (обычно не нужно){{else}}Вариант Б — у меня уже есть PostgreSQL, введу DSN сам{{end}}</summary>
{{if or devOn (not .Done.Postgres)}}<details style="margin-top:14px" {{if not .Settings.Postgres.DSN}}open{{end}}>
<summary style="cursor:pointer;color:var(--accent)">{{if .Settings.Postgres.DSN}}Подключить другую базу строкой подключения (без переноса данных){{else}}Вариант Б — у меня уже есть PostgreSQL, введу DSN сам{{end}}</summary>
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
<label>DSN (строка подключения) <span class="tooltip" title="Формат: postgres://пользователь:пароль@хост:порт/база?sslmode=disable. Например: postgres://bj:secret@db.example.com:5432/bj?sslmode=require">?</span></label>
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;margin-top:6px">
<p class="muted" style="margin-top:8px">При сохранении выполняется проверка подключения (Ping). Если БД недоступна — будет ошибка.</p>
<button type="submit" class="btn" style="margin-top:8px">Сохранить и проверить</button>
</form>
</details>
</details>{{end}}
{{template "wizNav" .}}
{{if and devOn (not .Done.Postgres)}}<p style="margin-top:8px"><a href="/admin/wizard?step=3&skip=postgres" style="font-size:13px">Пропустить (режим разработки: in-memory — без сохранения сделок)</a></p>{{end}}
@@ -634,7 +679,7 @@
<a href="https://{{serverIP}}:8443/admin/">https://{{serverIP}}:8443/</a>
</div>
<p class="muted" style="font-size:12.5px;margin:4px 0 0">Чтобы адрес не менялся, задайте постоянный: на консоли сервера — <code>sudo bj-network</code>.</p>
{{if .AutoDone}}<p class="muted" style="font-size:12.5px;margin:16px 0 0;padding-top:12px;border-top:1px solid var(--border)">Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}</p>{{end}}
{{if .AutoDone}}<p class="muted" style="font-size:12.5px;margin:16px 0 0;padding-top:12px;border-top:1px solid var(--border)">Уже сделано установщиком: {{range $i, $a := .AutoDone}}{{if $i}} · {{end}}✓ {{$a}}{{end}}{{if and .Done.Postgres (pgLocal .Settings.Postgres.DSN)}}. <a href="/admin/wizard?step=2">Базу bj можно держать и на своём сервере PostgreSQL →</a>{{end}}</p>{{end}}
<script>
(function(){ // пояс компьютера пользователя — предложить одной кнопкой (не менять молча: сервер может стоять в другом городе)
var sel=document.getElementById('wiz-tz-zone'), tz='';
@@ -763,6 +763,49 @@ details:has(> pre.copyable) { min-width:0; max-width:100%; }
});
})();
</script>
<style>.need-miss{border-color:var(--err)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--err) 18%,transparent)!important}
.need-msg{color:var(--err);font-size:13.5px;margin:8px 0 0;flex-basis:100%}</style>
<script>
/* Обязательные поля (06.10.2026): кнопка не уходит с пустыми видимыми полями — bj пишет, какие именно не заполнены,
подсвечивает их и ставит курсор в первое. Поля помечены required или data-need="как назвать"; скрытые (другая
вкладка, другой способ) не проверяются. window.bjNeed(form, btn) — то же для кнопок без отправки формы. */
(function(){
function visible(el){ return !el.disabled && el.type!=='hidden' && !!(el.offsetWidth||el.offsetHeight||el.getClientRects().length); }
function name(el){
var n=el.getAttribute('data-need')||el.getAttribute('aria-label')||'';
if(!n){ var l=el.closest('label'); if(l){ var c=l.cloneNode(true); c.querySelectorAll('input,select,textarea,button,.muted,.tooltip').forEach(function(x){x.remove()}); n=c.textContent.replace(/\s+/g,' ').replace(/\(.*?\)/g,'').trim(); } }
return n||el.placeholder||el.name;
}
function msgBox(f, btn){
var host=(btn&&btn.parentElement)||f, m=host.querySelector(':scope > .need-msg');
if(!m){ m=document.createElement('div'); m.className='need-msg'; m.setAttribute('role','alert'); host.appendChild(m); }
return m;
}
window.bjNeed=function(f, btn){
var miss=[], bad=[], first=null;
f.querySelectorAll('[required],[data-need]').forEach(function(el){
el.classList.remove('need-miss');
if(!visible(el)) return;
var empty = el.type==='file' ? !(el.files&&el.files.length) : (el.type==='checkbox'||el.type==='radio') ? false : !String(el.value||'').trim();
if(empty){ miss.push(name(el)); el.classList.add('need-miss'); first=first||el; }
else if(el.checkValidity && !el.checkValidity()){ bad.push(name(el)); el.classList.add('need-miss'); first=first||el; }
});
var old=f.querySelectorAll('.need-msg'); old.forEach(function(x){ x.remove(); });
if(!first) return true;
var t=[]; if(miss.length) t.push('Не заполнено: '+miss.join(', ')); if(bad.length) t.push('Заполнено неверно: '+bad.join(', '));
msgBox(f, btn).textContent=t.join('. ')+'.';
try{ first.focus({preventScroll:false}); first.scrollIntoView({block:'center',behavior:'smooth'}); }catch(e){}
return false;
};
document.addEventListener('DOMContentLoaded',function(){ document.querySelectorAll('form').forEach(function(f){ f.noValidate=true; }); });
document.addEventListener('submit',function(e){
var f=e.target; if(!(f instanceof HTMLFormElement)) return;
var b=e.submitter; if(b&&b.hasAttribute('formnovalidate')) return;
if(!window.bjNeed(f, b)){ e.preventDefault(); e.stopImmediatePropagation(); }
}, true);
document.addEventListener('input',function(e){ var el=e.target; if(el.classList&&el.classList.contains('need-miss')&&String(el.value||'').trim()) el.classList.remove('need-miss'); });
})();
</script>
</body>
</html>
{{end}}
@@ -6,7 +6,7 @@
<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>SMTP-сервер</label><input type="text" name="host" data-need="SMTP-сервер" 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)">
@@ -18,7 +18,7 @@
<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>Отправитель (From)</label><input type="text" name="from" data-need="отправитель (From)" value="{{.Settings.Alerts.SMTP.From}}" placeholder="bj@example.ru" style="margin-top:3px"></div>
</div>
<div style="margin-top:12px;max-width:420px"><label>Проверочное письмо — кому</label><input type="text" name="test_to" value="{{if .Settings.Alerts.SMTP.TestTo}}{{.Settings.Alerts.SMTP.TestTo}}{{else}}{{.Settings.Alerts.SMTP.To}}{{end}}" placeholder="it@example.ru" style="margin-top:3px;width:100%">
<span class="muted" style="font-size:12px;display:block;margin-top:4px;line-height:1.4">По умолчанию — администратор системы из блока «Кому писать».</span></div>
@@ -9,13 +9,13 @@
<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>Сетевая папка — как в проводнике<input name="share" value="{{smbWin $b.Share}}" placeholder="\\nas\backup"></label>
<label>Сетевая папка — как в проводнике<input name="share" data-need="сетевая папка" value="{{smbWin $b.Share}}" placeholder="\\nas\backup"></label>
<label>Подпапка для копий<input name="dir" value="{{$b.Dir}}" placeholder="bj-backup"></label>
</div>
<p class="muted" style="font-size:12px;margin:-4px 0 0">Например <code>\\10.10.10.11\backup</code> — та же папка, что открывается в проводнике Windows с этим логином. Копии ложатся в подпапку внутри неё.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Логин хранилища<input name="user" value="{{$b.User}}" autocomplete="off"></label>
<label>Пароль {{if $b.PasswordSet}}<span class="muted">(задан; пусто — оставить)</span>{{end}}<input name="password" type="password" autocomplete="new-password"></label>
<label>Логин хранилища<input name="user" data-need="логин хранилища" value="{{$b.User}}" autocomplete="off"></label>
<label>Пароль {{if $b.PasswordSet}}<span class="muted">(задан; пусто — оставить)</span>{{end}}<input name="password" type="password" {{if not $b.PasswordSet}}data-need="пароль хранилища"{{end}} 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>
@@ -39,7 +39,7 @@
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='Проверяю доступ…';
document.getElementById('bk-test').onclick=function(){ if(window.bjNeed&&!window.bjNeed(this.form,this)) return; 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}
@@ -47,7 +47,7 @@
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='Читаю список…';
document.getElementById('bk-list').onclick=function(){ if(window.bjNeed&&!window.bjNeed(this.form,this)) return; 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>. «Проверить» скачивает копию и сверяет контрольные суммы — ничего не восстанавливает.'+
@@ -58,6 +58,23 @@
<button type="button" class="btn" id="ig-goprod" style="background:var(--err);border-color:var(--err)">Перейти на боевой</button>
</div>
</div>
<div class="ig-job" id="ig-job" hidden aria-live="polite">
<div class="ig-job-h"><span class="ig-spin"></span><b id="ig-job-t">Записываю канал в шлюз НРД</b><span class="muted" id="ig-job-time"></span></div>
<ol class="dq-steps" id="ig-job-steps"></ol>
<p class="muted" id="ig-job-note" style="margin:8px 0 0;font-size:13px">Обычно до двух минут: шлюз перезапускается. Не уходите со страницы — после перезагрузки ход покажется снова.</p>
</div>
<style>
.ig-job{margin-top:12px;border:2px solid var(--accent);border-radius:12px;padding:14px 16px;background:var(--accent-weak)}
.ig-job.is-ok{border-color:var(--ok);background:color-mix(in srgb,var(--ok) 8%,var(--card))}
.ig-job.is-err{border-color:var(--err);background:color-mix(in srgb,var(--err) 7%,var(--card))}
.ig-job-h{display:flex;gap:10px;align-items:center;margin-bottom:8px;flex-wrap:wrap}
.ig-job-h .muted{margin-left:auto;font-variant-numeric:tabular-nums}
.ig-spin{width:18px;height:18px;border-radius:50%;border:2px solid var(--accent-weak);border-top-color:var(--accent);animation:dq-spin .8s linear infinite;flex:none}
.ig-job.is-ok .ig-spin,.ig-job.is-err .ig-spin{display:none}
.ig-job.shake{animation:ig-shake .4s}
@keyframes ig-shake{25%{transform:translateX(-6px)}75%{transform:translateX(6px)}}
@keyframes dq-spin{to{transform:rotate(360deg)}}
</style>
<div id="ig-cout" style="margin-top:8px;font-size:13px"></div>
</div>
</div>
@@ -67,6 +84,50 @@
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function ok(t){return '<span style="color:var(--ok)">✓ '+esc(t)+'</span>'} function bad(t){return '<span style="color:var(--err)">✗ '+esc(t)+'</span>'}
function post(u,p){return fetch(u,{method:'POST',body:p,cache:'no-store'}).then(function(r){return r.json()})}
// Запись канала идёт в фоне bj: показываем её ход (и после перезагрузки страницы), пока идёт — не даём уйти.
var job=(function(){
var box=document.getElementById('ig-job'), list=document.getElementById('ig-job-steps'), tt=document.getElementById('ig-job-t'),
tm=document.getElementById('ig-job-time'), note=document.getElementById('ig-job-note'), busy=false, t0=0, timer=0, fails=0;
var ST=[['write','Запись канала в базу шлюза'],['engine','Перезапуск шлюза и запуск движка'],['channel','Проверка: шлюз загрузил канал']];
function draw(v){
var idx=-1; ST.forEach(function(x,i){ if(x[0]===v.stage) idx=i; }); if(v.done) idx=ST.length;
list.innerHTML=ST.map(function(x,i){ var cls=i<idx?'is-ok':(i===idx?(v.done?'':'is-run'):'is-wait'); if(v.done&&!v.ok&&i===Math.min(idx,ST.length)-1) cls='is-err';
return '<li class="dq-st '+cls+'"><span class="dq-st-ic"></span><div class="dq-st-t"><span>'+x[1]+'</span></div></li>'; }).join('');
}
function tick(){ if(!t0) return; var n=Math.max(0,Math.round(Date.now()/1000-t0)); tm.textContent=Math.floor(n/60)+':'+('0'+n%60).slice(-2); }
function finish(v){
busy=false; clearInterval(timer); box.classList.remove('shake');
box.classList.add(v.ok?'is-ok':'is-err'); tt.textContent=v.ok?'Канал '+String(v.contour||'').toUpperCase()+' записан, шлюз работает':'Канал не записан';
note.textContent=v.msg||''; draw(v); load();
if(v.ok && /перезапускается/.test(v.msg||'')) setTimeout(function(){ location.reload(); },20000);
}
function poll(){
fetch('/admin/setup/igate/contour-status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(v){
fails=0; if(v.started) t0=v.started;
if(v.running){ draw(v); setTimeout(poll,2000); return; }
if(v.done) finish(v); else { busy=false; box.hidden=true; }
}).catch(function(){ if(++fails<30) setTimeout(poll,3000); }); // bj мог перезапуститься — ждём
}
function watch(c){
busy=true; box.hidden=false; box.classList.remove('is-ok','is-err'); tt.textContent='Записываю канал '+String(c||'').toUpperCase()+' в шлюз НРД';
note.textContent='Обычно до двух минут: шлюз перезапускается. Не уходите со страницы — после перезагрузки ход покажется снова.';
t0=t0||Date.now()/1000; clearInterval(timer); timer=setInterval(tick,1000); tick();
box.scrollIntoView({block:'center',behavior:'smooth'}); poll();
}
// пока идёт запись — переходы и кнопки вне карточки не срабатывают
document.addEventListener('click',function(e){
if(!busy) return; var a=e.target.closest('a[href],button,input[type=submit]'); if(!a||box.contains(a)) return;
e.preventDefault(); e.stopPropagation(); box.classList.remove('shake'); void box.offsetWidth; box.classList.add('shake');
note.textContent='Подождите: идёт запись канала, шлюз перезапускается. Переход станет доступен, когда она закончится.';
box.scrollIntoView({block:'center',behavior:'smooth'});
},true);
window.addEventListener('beforeunload',function(e){ if(busy){ e.preventDefault(); e.returnValue=''; } });
// при открытии страницы: запись уже идёт или только что закончилась — показать
fetch('/admin/setup/igate/contour-status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(v){
if(v.running){ t0=v.started||0; watch(v.contour); } else if(v.done){ box.hidden=false; t0=0; finish(v); }
}).catch(function(){});
return {watch:watch};
})();
var st=null;
function load(){
fetch('/admin/setup/igate/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
@@ -76,6 +137,7 @@
var running=d.installed&&d.engine==='Running';
if(!d.installed) head.innerHTML=bad('ИШ на этом сервере не установлен');
else head.innerHTML=(running?ok('ИШ '+d.version+' установлен и работает'):bad('ИШ '+d.version+' установлен, но не работает: движок '+(d.engine||'не отвечает')+', служба '+d.service));
if(d.busy) head.innerHTML='<span style="color:var(--accent)">⟳ Шлюз перезапускается — '+esc(d.busy)+'; состояние — последнее известное</span>';
// не стоит или не работает — одна кнопка: поставить заново проверенную версию с сервера (систему переставлять не нужно)
ri.style.display=running?'none':'block';
if(!running){
@@ -106,8 +168,9 @@
function act(a,c,b){
if(a==='showprod'){ document.getElementById('ig-prod').style.display='block'; return; }
var p=new URLSearchParams(); p.set('contour',c); p.set('crypto',document.getElementById('ig-crypto').value);
if(a==='contour'){ b.disabled=true; cout.textContent='Записываю канал и перезапускаю ИШ (до 2 минут)…';
post('/admin/setup/igate/contour',p).then(function(d){ b.disabled=false; cout.innerHTML=d.ok?ok(d.output):bad(d.error); load(); }); }
if(a==='contour'){ b.disabled=true; cout.textContent='';
post('/admin/setup/igate/contour',p).then(function(d){ b.disabled=false; if(!d.ok){ cout.innerHTML=bad(d.error); return; } job.watch(c); })
.catch(function(){ b.disabled=false; job.watch(c); }); }
if(a==='switch'){ if(!confirm('Переключить bj на тестовый контур TEST3?')) return; cout.textContent='Переключаю…';
post('/admin/setup/igate/switch',p).then(function(d){ cout.innerHTML=d.ok?ok(d.output):bad(d.error); if(d.ok) setTimeout(function(){location.reload()},15000); }); }
}
@@ -9,17 +9,17 @@
<input type="hidden" name="check" value="1">
<div style="display:grid;grid-template-columns:2.2fr .8fr 1.6fr;gap:10px">
<label>Сервер QUIK МНП
<input type="text" name="server" value="{{.Settings.Quik.Server}}" placeholder="адрес или имя\экземпляр" style="width:100%" required></label>
<input type="text" name="server" data-need="адрес сервера QUIK МНП" value="{{.Settings.Quik.Server}}" placeholder="адрес или имя\экземпляр" style="width:100%" required></label>
<label>Порт
<input type="number" name="port" value="{{if .Settings.Quik.Port}}{{.Settings.Quik.Port}}{{else}}1433{{end}}" style="width:100%"></label>
<label>База МНП{{/* без имени базы модуль не подключается — обязательно */}}
<input type="text" name="database" value="{{.Settings.Quik.Database}}" placeholder="имя базы модуля" style="width:100%" required spellcheck="false"></label>
<input type="text" name="database" data-need="имя базы модуля неторговых поручений" value="{{.Settings.Quik.Database}}" placeholder="имя базы модуля" style="width:100%" required spellcheck="false"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<label>Логин
<input type="text" name="user" value="{{.Settings.Quik.User}}" style="width:100%" required autocomplete="off"></label>
<input type="text" name="user" data-need="логин SQL для QUIK" value="{{.Settings.Quik.User}}" style="width:100%" required autocomplete="off"></label>
<label>Пароль
<input type="password" name="password" placeholder="{{if .Settings.Quik.Password}}сохранён — пусто, чтобы не менять{{end}}" autocomplete="new-password" style="width:100%"></label>
<input type="password" name="password" {{if not .Settings.Quik.Password}}data-need="пароль SQL для QUIK"{{end}} placeholder="{{if .Settings.Quik.Password}}сохранён — пусто, чтобы не менять{{end}}" autocomplete="new-password" style="width:100%"></label>
</div>
<details {{if quikNeedsTrust .Settings.Quik}}open{{end}}><summary style="cursor:pointer;color:var(--accent);font-size:13px">Дополнительно: шифрование</summary>
<div style="display:grid;gap:10px;margin-top:8px">
+8 -1
View File
@@ -393,8 +393,15 @@ func (h *setupHandlers) depoSetupDo(ctx context.Context, r *http.Request, step d
switch mode {
case "auto":
au := strings.TrimSpace(r.FormValue("admin_user"))
var miss []string
if au == "" {
return fail("введите логин и пароль администратора SQL (они используются один раз и не сохраняются)")
miss = append(miss, "логин администратора SQL")
}
if r.FormValue("admin_password") == "" {
miss = append(miss, "пароль администратора SQL")
}
if len(miss) > 0 {
return fail("не заполнено: " + strings.Join(miss, ", ") + " (они используются один раз и не сохраняются)")
}
if bad := depoBjCreds(r, &s); bad != "" {
return fail(bad)