«Подготовка модулей» между ключом продукта и мастером: bj узнаёт состав лицензии, ставит модули, которые в неё входят (из комплекта образа, если там тот же выпуск, — без скачивания), запускает их службы и открывает мастер; после активации ключа и при изменении состава лицензии модули ставятся сразу, а не при следующем запуске bj

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-05 19:25:17 +03:00
parent 64eb63efc2
commit 0dd38bbae4
8 changed files with 413 additions and 9 deletions
+6 -2
View File
@@ -26,7 +26,7 @@ type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
wizard, news, keyWizard, prepare *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim, depoTest *template.Template
journal *template.Template
@@ -304,6 +304,10 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_wizard: %w", err)
}
prepare, err := parse("admin_prepare.html")
if err != nil {
return nil, fmt.Errorf("parse admin_prepare: %w", err)
}
news, err := parse("admin_news.html")
if err != nil {
return nil, fmt.Errorf("parse admin_news: %w", err)
@@ -380,7 +384,7 @@ func newAdmin() (*admin, error) {
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage,
wizard: wizard, news: news, keyWizard: keyWizard,
wizard: wizard, news: news, keyWizard: keyWizard, prepare: prepare,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
m2mtest: m2mtest,
+1 -1
View File
@@ -171,7 +171,7 @@ func registerFirstRun(mux *http.ServeMux) {
http.Redirect(w, r, "/admin/wizard?step=1&flash="+url.QueryEscape("Администратор создан. Ключ продукта не принят: "+err.Error()+". Проверьте его и введите ещё раз."), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/wizard?flash="+url.QueryEscape("Администратор создан, лицензия активирована. Мастер проведёт по остальным шагам — любой можно пропустить."), http.StatusSeeOther)
http.Redirect(w, r, "/admin/setup/prepare?next=/admin/wizard&flash="+url.QueryEscape("Администратор создан, ключ продукта принят."), http.StatusSeeOther)
return
}
if d.NeedKey {
+39 -1
View File
@@ -14,6 +14,7 @@ import (
"errors"
"fmt"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/hwid"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
"io"
"log"
"net/http"
@@ -167,6 +168,7 @@ func LicenseActivate(ctx context.Context, rc *RuntimeConfig, code string) error
return err
}
log.Printf("lk-gateway: лицензия активирована на сервере лицензий: %s, лицензия %s", r.Tenant, r.LicenseID)
afterLicenseChange(rc)
return nil
}
@@ -196,10 +198,46 @@ func LicenseRenew(ctx context.Context, rc *RuntimeConfig) error {
if err := licenseAccept(rc, r.Token, "продлена"); err != nil {
return err
}
enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие
if nl, err := verifyLicenseToken(r.Token); err == nil && licenseScopeChanged(lic, nl) {
afterLicenseChange(rc) // в лицензию добавили модуль или план — поставить, не дожидаясь перезапуска bj
} else {
enforceModuleLicenseAsync(rc) // состав лицензии мог измениться — службы модулей в соответствие
}
return nil
}
// licenseScopeChanged — изменился ли состав лицензии: план или признаки модулей.
func licenseScopeChanged(a, b *license.License) bool {
if a == nil || b == nil {
return a != b
}
if a.Plan != b.Plan || len(a.Features) != len(b.Features) {
return true
}
for _, f := range b.Features {
if !a.HasFeature(f) {
return true
}
}
return false
}
// afterLicenseChange — лицензию активировали или изменили её состав: службы модулей — в соответствие
// лицензии, а модули, которые в неё входят, но ещё не стоят, ставятся сразу. На сервере с образа ключ
// вводят уже после установки, и без этого модули (служба подписи и др.) встали бы только после
// перезапуска bj.
func afterLicenseChange(rc *RuntimeConfig) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
defer cancel()
enforceModuleLicense(ctx, rc)
if _, err := os.Stat(keymediaReqDir); err != nil {
return // нет root-службы (разработка, тесты)
}
_, _ = NewUpdater(rc).ensureSplitModules(ctx)
}()
}
// licenseNeedsRenew — онлайн-ключ и 15 минут без продления: так режим разработки, включённый или
// выключенный на сервере лицензий, доходит до установки быстро. После неудачи — не чаще раза в час.
func licenseNeedsRenew(rc *RuntimeConfig) bool {
+65
View File
@@ -12,6 +12,9 @@ import (
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
func TestAccPrefix(t *testing.T) {
@@ -273,3 +276,65 @@ func TestNotifyKindsByLicense(t *testing.T) {
t.Fatalf("маршруты после сохранения: %+v", r)
}
}
// Модули ставятся сразу, когда состав лицензии изменился (план или признаки), а не при каждом продлении.
func TestLicenseScopeChanged(t *testing.T) {
a := &license.License{Plan: "pro", Features: []string{"updates", "quik-mnp"}}
same := &license.License{Plan: "pro", Features: []string{"quik-mnp", "updates"}}
more := &license.License{Plan: "pro", Features: []string{"updates", "quik-mnp", "npr"}}
other := &license.License{Plan: "enterprise", Features: []string{"updates", "quik-mnp"}}
if licenseScopeChanged(a, same) {
t.Fatal("тот же состав в другом порядке — не изменение")
}
if !licenseScopeChanged(a, more) || !licenseScopeChanged(a, other) || !licenseScopeChanged(nil, a) {
t.Fatal("новый модуль или план — изменение")
}
}
// «Подготовка модулей»: страница собирается, переход — только на свои страницы, без лицензии — одно ядро.
func TestPrepareScreen(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
var b strings.Builder
d := PrepareData{page: page{Title: "Подготовка модулей", Active: "wizard"}, Tenant: "ТЕСТ ZETIT", Plan: "pro", Until: "активна до 04.12.2026", Next: "/admin/wizard", Flash: "Администратор создан, ключ продукта принят."}
if err := a.prepare.ExecuteTemplate(&b, "layout", d); err != nil {
t.Fatal(err)
}
for _, want := range []string{"Подготовка модулей", "ТЕСТ ZETIT", "/admin/setup/prepare/status", "Перейти к настройке"} {
if !strings.Contains(b.String(), want) {
t.Fatalf("нет %q", want)
}
}
for in, want := range map[string]string{"": "/admin/wizard", "/admin/setup?x=1": "/admin/setup?x=1", "https://evil": "/admin/wizard", "//evil/admin/": "/admin/wizard", "/admin/wizard\r\nX: y": "/admin/wizard"} {
if got := safeNext(in); got != want {
t.Errorf("safeNext(%q) = %q, ожидалось %q", in, got, want)
}
}
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
if items := prepareItems(rc, false, false, ""); len(items) != 1 || items[0].State != "ok" {
t.Fatalf("без лицензии — только ядро: %+v", items)
}
}
// Модуль из комплекта образа берётся без скачивания, только если это тот же файл, что в выпуске.
func TestFromLocalBundle(t *testing.T) {
dir, staging := t.TempDir(), t.TempDir()
old := localBundleDir
localBundleDir = dir
defer func() { localBundleDir = old }()
if err := os.WriteFile(filepath.Join(dir, "crypto-service.jar"), []byte("jar-2026.09.25.14"), 0o644); err != nil {
t.Fatal(err)
}
sum, _, _ := release.HashFile(filepath.Join(dir, "crypto-service.jar"))
if !fromLocalBundle(release.Artifact{Name: "crypto-service.jar", File: "crypto-service.jar", SHA256: sum}, staging) {
t.Fatal("тот же файл — должен взяться из образа")
}
if b, _ := os.ReadFile(filepath.Join(staging, "crypto-service.jar")); string(b) != "jar-2026.09.25.14" {
t.Fatalf("скопировано: %q", b)
}
if fromLocalBundle(release.Artifact{Name: "crypto-service.jar", File: "crypto-service.jar", SHA256: strings.Repeat("0", 64)}, t.TempDir()) {
t.Fatal("другой выпуск — качать с сервера, а не брать из образа")
}
}
+190
View File
@@ -0,0 +1,190 @@
package lkgateway
// «Подготовка модулей» (05.10.2026) — экран между вводом ключа продукта и мастером настройки. Образ ставит
// базовый набор (ядро); после ввода ключа bj узнаёт состав лицензии, ставит модули, которые в неё входят
// (файлы — из комплекта образа, если там тот же выпуск, иначе с сервера обновлений), запускает их службы
// и только потом открывает мастер: мастер настраивает уже установленное.
import (
"context"
"net/http"
"os"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
// PrepareItem — строка экрана: что входит в лицензию и в каком оно состоянии.
type PrepareItem struct {
Title string `json:"title"`
State string `json:"state"` // ok | run | wait | warn | err | info
Detail string `json:"detail,omitempty"`
}
var prepareRun struct {
sync.Mutex
Running, Finished bool
Result, Err string
}
// prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет).
var prepareCoreFeatures = []string{"quik-mnp", "esia-finance"}
// prepareItems — состав лицензии этой установки и состояние каждого модуля.
func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem {
out := []PrepareItem{{Title: "Ядро bj: обмен с НРД, переводы M2M", State: "ok", Detail: "установлено с образа, версия " + BuildVersion}}
for _, f := range prepareCoreFeatures {
if moduleAllowed(rc, f) {
out = append(out, PrepareItem{Title: moduleTitles[f], State: "ok", Detail: "входит в ядро — устанавливать нечего"})
}
}
for _, c := range release.Components {
if !c.FromCore || !moduleAllowed(rc, c.Feature) {
continue
}
it := PrepareItem{Title: prepareTitle(c)}
_, statErr := os.Stat(c.Path)
present := statErr == nil
us := ""
if present && c.Unit != "" {
us = unitState(c.Unit).State
}
switch {
case us == "active":
it.State, it.Detail = "ok", "установлен, служба работает"
case us == "activating" || us == "reloading":
it.State, it.Detail = "run", "установлен, служба запускается…"
case present && c.Unit == "bj-crypto":
it.State, it.Detail = "warn", "установлен, служба не запущена ("+us+") — обмену не мешает: пакеты для НРД подписывает шлюз"
case present:
it.State, it.Detail = "ok", "установлен; служба заработает, когда модуль будет настроен (глава «Запуск»)"
case running:
it.State, it.Detail = "run", "устанавливается…"
case runErr != "":
it.State, it.Detail = "err", "не установлен — "+runErr
case finished:
it.State, it.Detail = "warn", "не установлен: в текущем выпуске на сервере обновлений его нет — поставится обновлением"
default:
it.State, it.Detail = "wait", "будет установлен"
}
out = append(out, it)
}
if moduleAllowed(rc, "web-cabinet") {
out = append(out, PrepareItem{Title: moduleTitles["web-cabinet"], State: "info", Detail: "ставится на веб-сервер организации: дистрибутив и ключ кабинета — в мастере, глава «Запуск»"})
}
return out
}
// prepareTitle — название модуля для экрана.
func prepareTitle(c release.Component) string {
switch c.Name {
case "crypto-service.jar":
return "Служба пробной подписи (Валидата)"
}
if t := moduleTitles[c.Feature]; t != "" {
return t
}
return c.Title
}
// prepareStart — поставить модули лицензии (один запуск за раз; повторный — пока идёт — ничего не делает).
func prepareStart(rc *RuntimeConfig) {
prepareRun.Lock()
if prepareRun.Running {
prepareRun.Unlock()
return
}
prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err = true, false, "", ""
prepareRun.Unlock()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute)
defer cancel()
var msg, errText string
if _, err := os.Stat(keymediaReqDir); err != nil {
errText = "на сервере нет службы установки (bj-keymedia)"
} else if m, err := NewUpdater(rc).ensureSplitModules(ctx); err != nil {
errText = strings.TrimPrefix(err.Error(), "Установка модулей ")
} else {
msg = m
}
prepareRun.Lock()
prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err = false, true, msg, errText
prepareRun.Unlock()
}()
}
// safeNext — куда идти после подготовки: только свои страницы (как return_to).
func safeNext(v string) string {
if strings.HasPrefix(v, "/admin/") && !strings.HasPrefix(v, "//") && !strings.ContainsAny(v, "\r\n") {
return v
}
return "/admin/wizard"
}
// PrepareData — данные страницы «Подготовка модулей».
type PrepareData struct {
page
Tenant, Plan, Until string
Next string
Flash string
}
func registerPrepare(mux *http.ServeMux, h *setupHandlers) {
mux.HandleFunc("/admin/setup/prepare", func(w http.ResponseWriter, r *http.Request) {
st := licenseStatus(h.rc)
if !st.Valid {
http.Redirect(w, r, "/admin/wizard?step=1", http.StatusSeeOther)
return
}
prepareRun.Lock()
if !prepareRun.Running { // новый заход (ключ сменили, модуль добавили) — прошлый итог не в счёт
prepareRun.Finished, prepareRun.Result, prepareRun.Err = false, "", ""
}
prepareRun.Unlock()
d := PrepareData{page: nowPage("Подготовка модулей", "wizard"), Tenant: st.Tenant, Plan: st.Plan, Until: st.Message,
Next: safeNext(r.URL.Query().Get("next")), Flash: r.URL.Query().Get("flash")}
render(w, h.tpl.a.prepare, d)
})
mux.HandleFunc("/admin/setup/prepare/start", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
if !licenseStatus(h.rc).Valid {
jsonOut(w, map[string]any{"ok": false, "error": "лицензия не активирована"})
return
}
prepareStart(h.rc)
jsonOut(w, map[string]any{"ok": true})
})
mux.HandleFunc("/admin/setup/prepare/status", func(w http.ResponseWriter, r *http.Request) {
prepareRun.Lock()
running, finished, res, errText := prepareRun.Running, prepareRun.Finished, prepareRun.Result, prepareRun.Err
prepareRun.Unlock()
items := prepareItems(h.rc, running, finished, errText)
pending := false // модули лицензии, которых на сервере ещё нет
for _, c := range release.Components {
if c.FromCore && moduleAllowed(h.rc, c.Feature) {
if _, err := os.Stat(c.Path); err != nil {
pending = true
}
}
}
updProg.Lock()
pg := updProg.s
updProg.Unlock()
busy := false // служба модуля ещё запускается
for _, it := range items {
if it.State == "run" {
busy = true
}
}
out := map[string]any{"running": running, "finished": finished, "items": items, "pending": pending, "busy": busy, "result": res, "error": errText}
if running && pg.Active {
out["stage"], out["done"], out["total"] = pg.Stage, pg.Done, pg.Total
}
jsonOut(w, out)
})
}
+7 -2
View File
@@ -76,6 +76,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/fancy/acc-prefix", h.accPrefix)
mux.HandleFunc("/admin/setup/license", h.saveLicense)
mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate)
registerPrepare(mux, h) // «Подготовка модулей»: после ключа — модули лицензии, потом мастер
mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow)
mux.HandleFunc("/admin/setup/logo", h.saveLogo)
mux.HandleFunc("/admin/setup/logo/reset", h.resetLogo)
@@ -1425,8 +1426,12 @@ func (h *setupHandlers) licenseActivate(w http.ResponseWriter, r *http.Request)
setupFlash(w, r, "Лицензия: активация не удалась — "+err.Error())
return
}
st := licenseStatus(h.rc)
setupFlash(w, r, fmt.Sprintf("Лицензия активирована: %s, план %s, %s", st.Tenant, st.Plan, st.Message))
// дальше — «Подготовка модулей»: модули лицензии ставятся и запускаются, затем возврат туда, откуда пришли
next := safeReturnPath(r.FormValue("return_to"))
if strings.HasPrefix(next, "/admin/wizard") {
next = "/admin/wizard" // мастер сам откроется на первом несделанном шаге — теперь с шагами модулей лицензии
}
http.Redirect(w, r, "/admin/setup/prepare?next="+url.QueryEscape(next), http.StatusSeeOther)
}
// licenseRenewNow — POST /admin/setup/license/renew: продлить ключ сейчас.
+42 -3
View File
@@ -20,6 +20,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
@@ -340,6 +341,10 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro
skip := false
for _, a := range m.Artifacts {
if a.Name == c.Name {
if fromLocalBundle(a, staging) {
log.Printf("lk-gateway: %s %s — из комплекта образа, без скачивания", c.Name, pickVersion(c.Available))
continue
}
if _, err := cl.DownloadArtifact(ctx, a, staging); err != nil {
// Модуль сняли с лицензии, а bj ещё помнит прежний её состав (узнаёт при продлении):
// сервер обновлений файл не отдаёт — пропускаем модуль, остальное ставим.
@@ -408,10 +413,14 @@ func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, erro
// allowed — разрешён ли лицензией признак компонента.
func (u *Updater) allowed(feature string) bool { return moduleAllowed(u.rc, feature) }
var splitModulesMu sync.Mutex
// ensureSplitModules — выделенные из ядра модули, которые входят в лицензию, но ещё не стоят на этом
// сервере (например, ядро обновилось прежним установщиком), ставятся сами: без своих служб разделы
// этих модулей в bj не работают. Раз за запуск bj-server; ошибки — только в журнал и «Последнюю проверку».
func (u *Updater) ensureSplitModules(ctx context.Context) {
func (u *Updater) ensureSplitModules(ctx context.Context) (string, error) {
splitModulesMu.Lock() // запуск bj, активация ключа и «Подготовка модулей» — по очереди: второй увидит, что всё уже стоит
defer splitModulesMu.Unlock()
var names []string
installed := release.LoadInstalled(release.InstalledFile)
for _, c := range release.Plan("", nil, installed, BuildVersion) {
@@ -432,7 +441,7 @@ func (u *Updater) ensureSplitModules(ctx context.Context) {
}
}
if len(names) == 0 {
return
return "", nil
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
@@ -446,6 +455,36 @@ func (u *Updater) ensureSplitModules(ctx context.Context) {
cfg := u.rc.Snapshot().Update
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
_ = u.rc.SaveUpdateSettings(cfg)
return msg, err
}
// localBundleDir — комплект компонентов, с которого сервер ставился с образа (bj-firstboot).
var localBundleDir = "/var/lib/bj/bundle"
// fromLocalBundle — файл компонента из комплекта образа, если это тот же файл, что в выпуске (совпадает
// sha256): модуль, который уже лежит на диске, не качается заново — после ввода ключа он ставится сразу.
func fromLocalBundle(a release.Artifact, staging string) bool {
src := filepath.Join(localBundleDir, filepath.Base(a.File))
sum, _, err := release.HashFile(src)
if err != nil || !strings.EqualFold(sum, a.SHA256) {
return false
}
in, err := os.Open(src)
if err != nil {
return false
}
defer in.Close()
dst := filepath.Join(staging, filepath.Base(a.File))
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
if err != nil {
return false
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
_ = os.Remove(dst)
return false
}
return out.Close() == nil
}
// updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории:
@@ -536,7 +575,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
case <-time.After(time.Minute):
cleanupStaging(time.Hour)
enforceModuleLicense(ctx, u.rc)
u.ensureSplitModules(ctx)
_, _ = u.ensureSplitModules(ctx)
check()
}
for {
@@ -0,0 +1,63 @@
{{define "content"}}
<style>
.prep-head { text-align:center; padding:8px 0 4px; }
.prep-head h1 { font-size:26px; font-weight:720; letter-spacing:-0.02em; margin:0 0 6px; }
.prep-head p { color:var(--muted); margin:0 auto; max-width:620px; }
.prep { max-width:680px; margin:24px auto 0; }
.prep-row { display:grid; grid-template-columns:26px 1fr; gap:4px 10px; padding:12px 0; border-bottom:1px solid var(--border); }
.prep-row:last-child { border-bottom:0; }
.prep-row .ic { font-size:16px; line-height:22px; text-align:center; }
.prep-row b { font-weight:620; }
.prep-row .muted { font-size:13px; }
.prep-bar { height:6px; border-radius:3px; background:var(--card-2); overflow:hidden; margin:6px 0 0; }
.prep-bar i { display:block; height:100%; width:0; background:var(--accent); transition:width .4s ease; }
.prep-foot { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:16px; flex-wrap:wrap; }
@keyframes prepspin { to { transform:rotate(360deg); } }
.spin { display:inline-block; width:14px; height:14px; border:2px solid var(--accent-weak); border-top-color:var(--accent); border-radius:50%; animation:prepspin .8s linear infinite; vertical-align:-2px; }
@media (prefers-reduced-motion: reduce) { .spin { animation:none; } .prep-bar i { transition:none; } }
</style>
<div class="prep-head">
<h1>Подготовка модулей</h1>
<p>Ключ принят: <b>{{.Tenant}}</b>. bj ставит и запускает модули, которые входят в вашу лицензию, — затем откроется мастер настройки.</p>
</div>
<div class="card prep">
{{if .Flash}}<p style="margin:0 0 6px;color:var(--ok)">✓ {{.Flash}}</p>{{end}}
<p class="muted" style="margin:0 0 4px;font-size:13px">Лицензия: план {{.Plan}}, {{.Until}}</p>
<div id="prep-rows"><p class="muted" style="margin:12px 0">Проверяю состав лицензии…</p></div>
<div id="prep-prog" style="display:none;margin-top:8px"><span class="muted" id="prep-stage" style="font-size:13px"></span><div class="prep-bar"><i id="prep-fill"></i></div></div>
<div class="prep-foot">
<span id="prep-sum" style="font-weight:600"></span>
<a class="btn" id="prep-next" href="{{.Next}}" style="text-decoration:none;display:none">Перейти к настройке →</a>
</div>
</div>
<script>
(function(){
var rows=document.getElementById('prep-rows'), sum=document.getElementById('prep-sum'), nxt=document.getElementById('prep-next');
var prog=document.getElementById('prep-prog'), stage=document.getElementById('prep-stage'), fill=document.getElementById('prep-fill');
var next={{.Next}}, started=false, redirect=null;
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
var ic={ok:'<span style="color:var(--ok)">✓</span>',run:'<span class="spin" aria-label="идёт"></span>',wait:'<span class="muted">○</span>',warn:'<span style="color:var(--warn)">⚠</span>',err:'<span style="color:var(--err)">✗</span>',info:'<span class="muted">ⓘ</span>'};
function mb(n){return (n/1048576).toFixed(1).replace('.',',')+' МБ'}
function draw(d){
rows.innerHTML=d.items.map(function(it){return '<div class="prep-row"><div class="ic">'+ic[it.state]+'</div><div><b>'+esc(it.title)+'</b>'+(it.detail?'<br><span class="muted">'+esc(it.detail)+'</span>':'')+'</div></div>'}).join('');
if(d.running && d.stage){ prog.style.display=''; stage.textContent=d.stage+(d.total?' — '+mb(d.done)+' из '+mb(d.total):''); fill.style.width=(d.total?Math.min(100,Math.round(d.done*100/d.total)):15)+'%'; }
else prog.style.display='none';
var bad=d.items.filter(function(it){return it.state==='err'}).length, warn=d.items.filter(function(it){return it.state==='warn'}).length;
if(d.running || d.busy || (d.pending && !d.finished)){ sum.innerHTML='<span class="muted" style="font-weight:400">Ставлю и запускаю модули… обычно меньше минуты</span>'; nxt.style.display='none'; return false; }
nxt.style.display='';
if(bad){ sum.innerHTML='<span style="color:var(--err)">✗ Не всё установилось</span> <span class="muted" style="font-weight:400">— продолжить можно, модули поставятся из «Настройка → Обновления»</span>'; return true; }
if(warn){ sum.innerHTML='<span style="color:var(--ok)">✓ Готово</span> <span class="muted" style="font-weight:400">— есть замечание (⚠), продолжать можно</span>'; return true; }
sum.innerHTML='<span style="color:var(--ok)">✓ Модули готовы</span> <span class="muted" style="font-weight:400">— открываю мастер настройки…</span>';
if(!redirect) redirect=setTimeout(function(){ location.href=next; }, 2500);
return true;
}
function poll(){
fetch('/admin/setup/prepare/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(!started && d.pending && !d.running && !d.finished){ started=true; fetch('/admin/setup/prepare/start',{method:'POST',cache:'no-store'}).then(function(){ setTimeout(poll,600); }); draw(d); return; }
if(!draw(d)) setTimeout(poll,1500);
}).catch(function(){ setTimeout(poll,3000); });
}
poll();
})();
</script>
{{end}}