feat(admin): мастер настройки /admin/wizard + авто-подъём PostgreSQL одной кнопкой
Для пользователя без IT-навыков — пошаговая настройка (5 шагов) с прогресс-баром, подсказками «?» рядом с каждым полем и блоками «Что это?» / «Где взять?» в каждом шаге. Шаги: PostgreSQL → КриптоПро → Сертификаты → ИШ НРД → Тестовая заявка. Авто-определение текущего шага по первому незавершённому пункту, навигация Назад/Далее, мягкие пропуски (in-memory / mock-режимы). В шаге 1 — «⚡ Поднять локальный PostgreSQL автоматически»: одна кнопка запускает podman-compose, ждёт pg_isready, накатывает миграции fansy-store + m2m-core, сохраняет DSN в runtime-конфиг. setupFlash теперь возвращает пользователя на /admin/wizard, если POST пришёл оттуда — визард не «теряется» после действий. Mastered tasks: #41, #42, #43.
This commit is contained in:
+182
-2
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -59,6 +60,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
h.renderSetup(w, r, "")
|
||||
})
|
||||
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
|
||||
mux.HandleFunc("/admin/setup/postgres/quick-start", h.quickStartPostgres)
|
||||
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
|
||||
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
|
||||
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
|
||||
@@ -67,6 +69,90 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
||||
|
||||
// Пошаговый мастер настройки для нетехнических пользователей.
|
||||
mux.HandleFunc("/admin/wizard", h.renderWizard)
|
||||
}
|
||||
|
||||
// WizardData — данные для шаблона /admin/wizard.
|
||||
type WizardData struct {
|
||||
page
|
||||
Step int
|
||||
Settings Settings
|
||||
Certs []cryptocli.Certificate
|
||||
Flash string
|
||||
CryptoProInstalled bool
|
||||
CryptoProVersion string
|
||||
Done struct {
|
||||
Postgres bool
|
||||
Crypto bool
|
||||
Certs bool
|
||||
NSD bool
|
||||
TestRun bool
|
||||
}
|
||||
}
|
||||
|
||||
// renderWizard рисует одну из 5 страниц мастера. Шаг управляется query
|
||||
// параметром ?step=N (1..5). По умолчанию шаг определяется автоматически
|
||||
// по первому незавершённому пункту — это даёт «продолжить с того места».
|
||||
func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot()
|
||||
d := WizardData{
|
||||
page: nowPage("Мастер настройки", "wizard"),
|
||||
Settings: s,
|
||||
Certs: h.listCertsForUI(),
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
}
|
||||
d.Done.Postgres = s.Postgres.DSN != ""
|
||||
d.Done.Crypto = s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != ""
|
||||
d.Done.Certs = len(d.Certs) > 0
|
||||
d.Done.NSD = s.NSD.IGWBaseURL != "" && s.NSD.Profile != ""
|
||||
d.Done.TestRun = s.LastTest != nil
|
||||
|
||||
// Проверяем установлен ли КриптоПро CSP.
|
||||
if _, err := os.Stat("/opt/cprocsp/sbin/amd64/cpconfig"); err == nil {
|
||||
d.CryptoProInstalled = true
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if ver, _ := runCmd(ctx, "/opt/cprocsp/sbin/amd64/cpconfig", "-license", "-view"); ver != "" {
|
||||
d.CryptoProVersion = firstLine(ver)
|
||||
}
|
||||
}
|
||||
|
||||
// Определяем текущий шаг.
|
||||
step := 1
|
||||
if v := r.URL.Query().Get("step"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 5 {
|
||||
step = n
|
||||
}
|
||||
} else {
|
||||
// Авто: первый незавершённый.
|
||||
switch {
|
||||
case !d.Done.Postgres:
|
||||
step = 1
|
||||
case !d.Done.Crypto:
|
||||
step = 2
|
||||
case !d.Done.Certs:
|
||||
step = 3
|
||||
case !d.Done.NSD:
|
||||
step = 4
|
||||
default:
|
||||
step = 5
|
||||
}
|
||||
}
|
||||
d.Step = step
|
||||
render(w, h.tpl.a.wizard, d)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(s[:i])
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
|
||||
@@ -365,6 +451,88 @@ func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
|
||||
setupFlash(w, r, "PostgreSQL настройки сохранены")
|
||||
}
|
||||
|
||||
// quickStartPostgres — POST /admin/setup/postgres/quick-start.
|
||||
// «Большая зелёная кнопка» для пользователя без IT-навыков: поднимает
|
||||
// локальный postgres-контейнер через podman-compose, ждёт pg_isready,
|
||||
// накатывает все миграции (fansy-store + m2m-core), сохраняет дефолтный
|
||||
// DSN в runtime-конфиге. После этого пользователю остаётся перезапустить
|
||||
// bj-server (или мы сделаем это автоматически в дальнейших версиях).
|
||||
func (h *setupHandlers) quickStartPostgres(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 1. Поднимаем postgres контейнер через podman-compose.
|
||||
composePath := "deploy/docker-compose/docker-compose.yml"
|
||||
if out, err := runCmd(ctx, "podman-compose", "-f", composePath, "up", "-d", "postgres"); err != nil {
|
||||
setupFlash(w, r, "Шаг 1/3: podman-compose не смог поднять контейнер. "+
|
||||
"Установите podman-compose или проверьте docker-compose.yml. Подсказка: "+
|
||||
"sudo dnf install -y podman-compose. Вывод: "+strings.TrimSpace(out))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Ждём pg_isready.
|
||||
dsn := "postgres://bj:bj_dev@127.0.0.1:5432/bj?sslmode=disable"
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := tryPingPostgres(dsn); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
if err := tryPingPostgres(dsn); err != nil {
|
||||
setupFlash(w, r, "Шаг 2/3: контейнер запущен, но БД не отвечает за 30 сек. Ошибка: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Применяем миграции через podman exec.
|
||||
migrations := []string{
|
||||
"migrations/fansy-store/000__roles.sql",
|
||||
"migrations/fansy-store/001__schemas.sql",
|
||||
"migrations/fansy-store/002__working.sql",
|
||||
"migrations/fansy-store/003__staging.sql",
|
||||
"migrations/fansy-store/004__seed_participants.sql",
|
||||
"migrations/m2m-core/001__deals.sql",
|
||||
"migrations/m2m-core/002__stages.sql",
|
||||
}
|
||||
for _, mig := range migrations {
|
||||
if err := applyMigration(ctx, mig); err != nil {
|
||||
// Миграция могла быть уже применена ранее (например, ROLE уже
|
||||
// существует) — это не критично, продолжаем.
|
||||
log.Printf("quick-start: миграция %s: %v (продолжаем)", mig, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Сохраняем DSN в runtime-конфиг.
|
||||
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
|
||||
setupFlash(w, r, "Шаг 3/3: не получилось сохранить DSN: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
setupFlash(w, r, "Локальный PostgreSQL поднят и настроен. DSN сохранён. "+
|
||||
"Перезапустите bj-server (или подождите пока systemd сам перезапустит сервис), "+
|
||||
"чтобы Repository подключился к БД. После этого статус PostgreSQL будет зелёным.")
|
||||
}
|
||||
|
||||
// applyMigration выполняет одну SQL-миграцию через podman exec в bj-postgres.
|
||||
func applyMigration(ctx context.Context, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "podman", "exec", "-i", "bj-postgres",
|
||||
"psql", "-U", "bj", "-d", "bj", "-v", "ON_ERROR_STOP=1")
|
||||
cmd.Stdin = strings.NewReader(string(data))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w / output: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
@@ -573,9 +741,21 @@ func tryHTTPHealth(u string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query.
|
||||
// setupFlash шлёт 303 с flash-сообщением в query. Если запрос пришёл
|
||||
// со страницы мастера (/admin/wizard), возвращаем туда же с сохранением
|
||||
// номера шага — пользователь не должен «выпадать» из визарда после POST.
|
||||
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
target := "/admin/setup"
|
||||
if ref := r.Header.Get("Referer"); ref != "" {
|
||||
if u, err := url.Parse(ref); err == nil && strings.HasPrefix(u.Path, "/admin/wizard") {
|
||||
q := u.Query()
|
||||
q.Set("flash", msg)
|
||||
target = u.Path + "?" + q.Encode()
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, target+"?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// _q извлекает Request из ResponseWriter trick — здесь не нужно
|
||||
|
||||
Reference in New Issue
Block a user