Обновление с индикатором хода (этапы, скачанные МБ, перезапуск ядра); в мастере ключ продукта не просится повторно
- /admin/setup/update/progress: этап, байты, целевая версия ядра; ApplyUpdate/ApplyBundle отчитываются; - окно в layout: формы установки обновлений отправляются скриптом, окно ждёт перезапуска ядра и сверяет версию; - шаг 1 мастера: «Ключ продукта введён», смена ключа — под ссылкой. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -79,6 +79,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/update/check", h.checkUpdate)
|
||||
mux.HandleFunc("/admin/setup/update/apply", h.applyUpdate)
|
||||
mux.HandleFunc("/admin/setup/update/upload", h.uploadUpdate)
|
||||
mux.HandleFunc("/admin/setup/update/progress", h.updateProgress)
|
||||
mux.HandleFunc("/admin/setup/license", h.saveLicense)
|
||||
mux.HandleFunc("/admin/setup/license/activate", h.licenseActivate)
|
||||
mux.HandleFunc("/admin/setup/license/renew", h.licenseRenewNow)
|
||||
|
||||
@@ -147,6 +147,14 @@ func (u *Updater) saveCheckResult(st UpdateStatus, avail map[string]string) {
|
||||
// ApplyUpdate — скачать выбранные компоненты (names пусто — все, для которых есть обновление) и
|
||||
// передать установку root-установщику. Если среди них ядро, bj-server перезапустится до ответа.
|
||||
func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, error) {
|
||||
progressBegin(4)
|
||||
progressStage(1, "Проверка выпуска на сервере лицензий")
|
||||
msg, err := u.applyUpdate(ctx, names)
|
||||
progressEnd(msg, err)
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func (u *Updater) applyUpdate(ctx context.Context, names []string) (string, error) {
|
||||
if licensingEnabled(u.rc) {
|
||||
ls := licenseStatus(u.rc)
|
||||
if !ls.Valid {
|
||||
@@ -221,10 +229,24 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
|
||||
Names []string `json:"names"`
|
||||
New []string `json:"new,omitempty"`
|
||||
}
|
||||
var total int64
|
||||
for _, c := range selected {
|
||||
for _, a := range m.Artifacts {
|
||||
if a.Name == c.Name {
|
||||
total += a.Size
|
||||
}
|
||||
}
|
||||
if c.Name == "bj-server" {
|
||||
progressTarget(c.Available)
|
||||
}
|
||||
}
|
||||
progressStage(2, "Скачивание файлов")
|
||||
stopWatch := progressWatchDir(staging, total)
|
||||
for _, c := range selected {
|
||||
for _, a := range m.Artifacts {
|
||||
if a.Name == c.Name {
|
||||
if _, err := cl.DownloadArtifact(ctx, a, staging); err != nil {
|
||||
stopWatch()
|
||||
return "", fmt.Errorf("скачивание %s: %w", c.Name, err)
|
||||
}
|
||||
}
|
||||
@@ -235,10 +257,12 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
|
||||
}
|
||||
list = append(list, c.Name+" "+c.Available)
|
||||
}
|
||||
stopWatch()
|
||||
rb, _ := json.Marshal(req)
|
||||
if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
progressStage(3, "Проверка подписи и установка, затем перезапуск служб")
|
||||
log.Printf("lk-gateway: обновление компонентов: %s — передано root-установщику", strings.Join(list, ", "))
|
||||
wctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
@@ -406,6 +430,14 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
||||
// модули за ним. Модуль, которому нужно более новое ядро, чем будет после установки,
|
||||
// пропускается с объяснением.
|
||||
func (u *Updater) ApplyBundle(ctx context.Context, zipPath string) (string, error) {
|
||||
progressBegin(4)
|
||||
progressStage(1, "Распаковка и проверка пакета")
|
||||
msg, err := u.applyBundle(ctx, zipPath)
|
||||
progressEnd(msg, err)
|
||||
return msg, err
|
||||
}
|
||||
|
||||
func (u *Updater) applyBundle(ctx context.Context, zipPath string) (string, error) {
|
||||
defer os.Remove(zipPath)
|
||||
if licensingEnabled(u.rc) {
|
||||
ls := licenseStatus(u.rc)
|
||||
@@ -481,6 +513,16 @@ func (u *Updater) ApplyBundle(ctx context.Context, zipPath string) (string, erro
|
||||
if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, n := range req.Names {
|
||||
if n == "bj-server" {
|
||||
for _, c := range plan {
|
||||
if c.Name == n {
|
||||
progressTarget(c.Available)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
progressStage(3, "Проверка подписи и установка, затем перезапуск служб")
|
||||
log.Printf("lk-gateway: обновление из файла (релиз %s): %s — передано root-установщику", m.Version, strings.Join(list, ", "))
|
||||
wctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package lkgateway
|
||||
|
||||
// Ход обновления для окна с индикатором на странице: этапы (проверка, скачивание, установка,
|
||||
// перезапуск) и байты скачанного. Страница опрашивает GET /admin/setup/update/progress; когда
|
||||
// обновляется ядро, bj-server перезапускается — страница видит обрыв связи, ждёт и сверяет
|
||||
// версию работающего ядра с целевой.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type updateProgressState struct {
|
||||
Seq int64 `json:"seq"`
|
||||
Active bool `json:"active"`
|
||||
Finished bool `json:"finished"`
|
||||
Step int `json:"step"`
|
||||
Steps int `json:"steps"`
|
||||
Stage string `json:"stage"`
|
||||
Done int64 `json:"done"`
|
||||
Total int64 `json:"total"`
|
||||
Target string `json:"target,omitempty"` // версия ядра после обновления (если ядро обновляется)
|
||||
Result string `json:"result,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
Version string `json:"version"` // версия работающего bj-server
|
||||
}
|
||||
|
||||
var updProg = struct {
|
||||
sync.Mutex
|
||||
s updateProgressState
|
||||
}{}
|
||||
|
||||
func progressBegin(steps int) {
|
||||
updProg.Lock()
|
||||
defer updProg.Unlock()
|
||||
updProg.s = updateProgressState{Seq: updProg.s.Seq + 1, Active: true, Steps: steps, Started: time.Now()}
|
||||
}
|
||||
|
||||
func progressStage(step int, stage string) {
|
||||
updProg.Lock()
|
||||
defer updProg.Unlock()
|
||||
updProg.s.Step, updProg.s.Stage = step, stage
|
||||
}
|
||||
|
||||
func progressBytes(done, total int64) {
|
||||
updProg.Lock()
|
||||
defer updProg.Unlock()
|
||||
updProg.s.Done, updProg.s.Total = done, total
|
||||
}
|
||||
|
||||
func progressTarget(v string) {
|
||||
updProg.Lock()
|
||||
defer updProg.Unlock()
|
||||
updProg.s.Target = v
|
||||
}
|
||||
|
||||
func progressEnd(result string, err error) {
|
||||
updProg.Lock()
|
||||
defer updProg.Unlock()
|
||||
updProg.s.Active, updProg.s.Finished, updProg.s.Result = false, true, result
|
||||
if err != nil {
|
||||
updProg.s.Err = err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// progressWatchDir — пока идёт скачивание, раз в полсекунды считает байты в каталоге подготовки.
|
||||
// Возвращает функцию остановки.
|
||||
func progressWatchDir(dir string, total int64) func() {
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
t := time.NewTicker(500 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
var n int64
|
||||
_ = filepath.Walk(dir, func(_ string, fi os.FileInfo, err error) error {
|
||||
if err == nil && fi.Mode().IsRegular() {
|
||||
n += fi.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
progressBytes(n, total)
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(stop); wg.Wait(); progressBytes(total, total) }
|
||||
}
|
||||
|
||||
// updateProgressHandler — GET /admin/setup/update/progress.
|
||||
func (h *setupHandlers) updateProgress(w http.ResponseWriter, r *http.Request) {
|
||||
updProg.Lock()
|
||||
s := updProg.s
|
||||
updProg.Unlock()
|
||||
s.Version = BuildVersion
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(s)
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="help-block"><strong>Что это.</strong> Ключ продукта (BJ-XXXX-XXXX-XXXX) привязывает этот сервер к вашей лицензии.<br>
|
||||
<strong>Зачем.</strong> От лицензии зависят модули (обмен с DEPO и QUIK, личный кабинет, НПР/УДС) и получение обновлений: bj скачает и поставит модули, входящие в лицензию, автоматически.<br>
|
||||
<strong>Где взять.</strong> Портал клиента <a href="https://lic.zetit.ru/portal/" target="_blank">lic.zetit.ru/portal</a> или письмо от ZETIT.</div>
|
||||
{{if .Done.License}}<p>✓ Лицензия активна: <strong>{{.Lic.Tenant}}</strong>, план {{.Lic.Plan}}, модули: {{if .Lic.Features}}{{range $i, $f := .Lic.Features}}{{if $i}}, {{end}}{{$f}}{{end}}{{else}}только ядро{{end}}; {{.Lic.Message}}</p>{{end}}
|
||||
{{if .Done.License}}<p>✓ <strong>Ключ продукта введён</strong>, лицензия активна: <strong>{{.Lic.Tenant}}</strong>, план {{.Lic.Plan}}, модули: {{if .Lic.Features}}{{range $i, $f := .Lic.Features}}{{if $i}}, {{end}}{{$f}}{{end}}{{else}}только ядро{{end}}; {{.Lic.Message}}</p>{{end}}
|
||||
{{with .Update}}{{if .HasUpdate}}
|
||||
<div style="border:1px solid var(--accent);border-radius:8px;padding:12px 14px;margin-top:12px;background:var(--accent-weak)">
|
||||
<p style="margin:0 0 6px;font-weight:600">На сервере лицензий есть что поставить (выпуск {{.Available}})</p>
|
||||
@@ -61,12 +61,20 @@
|
||||
</form>
|
||||
</div>
|
||||
{{else}}<p class="muted" style="font-size:13px">✓ Установлена последняя версия bj ({{.Available}}).</p>{{end}}{{end}}
|
||||
{{if .Done.License}}<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--accent);font-size:13px">Сменить ключ продукта (обычно не нужно)</summary>
|
||||
<form method="post" action="/admin/setup/license/activate" style="margin-top:12px;display:grid;gap:10px">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=1">
|
||||
<label>Ключ продукта</label>
|
||||
<input type="text" name="code" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" style="padding:8px;text-transform:uppercase;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<button type="submit" class="btn" style="justify-self:start">{{if .Done.License}}Сменить ключ{{else}}Активировать{{end}}</button>
|
||||
</form>
|
||||
</details>{{else}}
|
||||
<form method="post" action="/admin/setup/license/activate" style="margin-top:12px;display:grid;gap:10px">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=1">
|
||||
<label>Ключ продукта</label>
|
||||
<input type="text" name="code" placeholder="BJ-XXXX-XXXX-XXXX" autocomplete="off" style="padding:8px;text-transform:uppercase;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<button type="submit" class="btn" style="justify-self:start">{{if .Done.License}}Сменить ключ{{else}}Активировать{{end}}</button>
|
||||
</form>{{end}}
|
||||
<div style="margin-top:20px;display:flex;justify-content:space-between"><span></span>
|
||||
{{if .Done.License}}<a href="/admin/wizard?step=2" class="btn" style="text-decoration:none">К шагу 2 →</a>{{else}}<a href="/admin/wizard?step=2" class="btn" style="background:var(--card);color:var(--text);border:1px solid var(--border-strong,#c9cdd8);text-decoration:none">Пропустить (ввести позже) →</a>{{end}}</div>
|
||||
</div>
|
||||
|
||||
@@ -572,6 +572,80 @@ label { font-size:13px; font-weight:500; }
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<div id="upd-modal" style="display:none;position:fixed;inset:0;background:rgba(10,14,24,.55);z-index:9999;align-items:center;justify-content:center;padding:16px">
|
||||
<div style="background:var(--card,#fff);color:var(--text,#111);border-radius:12px;max-width:520px;width:100%;padding:22px 24px;box-shadow:0 12px 40px rgba(0,0,0,.35)">
|
||||
<h3 style="margin:0 0 4px;font-size:18px">Обновление bj</h3>
|
||||
<p id="upd-stage" style="margin:0 0 12px;color:var(--muted,#667)">Подготовка…</p>
|
||||
<div style="height:12px;background:var(--card-2,#eef0f4);border-radius:6px;overflow:hidden;border:1px solid var(--border,#dde)">
|
||||
<div id="upd-bar" style="height:100%;width:0;background:var(--accent,#2b50d8);transition:width .4s"></div>
|
||||
</div>
|
||||
<p id="upd-detail" style="margin:8px 0 0;font-size:13px;color:var(--muted,#667);min-height:18px"></p>
|
||||
<div id="upd-done" style="display:none;margin-top:14px;text-align:right"><button type="button" class="btn" onclick="location.reload()">Готово</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
/* Окно с ходом обновления: формы установки обновлений отправляются из скрипта, а страница
|
||||
опрашивает /admin/setup/update/progress. При обновлении ядра bj перезапускается — связь
|
||||
пропадает, окно ждёт возвращения и сверяет версию ядра с целевой. */
|
||||
(function(){
|
||||
var modal=document.getElementById('upd-modal'); if(!modal) return;
|
||||
var stageEl=document.getElementById('upd-stage'), bar=document.getElementById('upd-bar'),
|
||||
detail=document.getElementById('upd-detail'), doneEl=document.getElementById('upd-done');
|
||||
function mb(n){ return (n/1048576).toFixed(n<10485760?1:0)+' МБ'; }
|
||||
function show(){ modal.style.display='flex'; }
|
||||
function finish(text, ok){
|
||||
stageEl.textContent=text; bar.style.width='100%';
|
||||
bar.style.background= ok ? 'var(--ok,#2e8b57)' : 'var(--err,#c0392b)';
|
||||
doneEl.style.display='block';
|
||||
}
|
||||
function run(form){
|
||||
var startVer=null, target='', seenActive=false, postDone=false, lost=false, t0=Date.now(), seq0=null;
|
||||
show(); stageEl.textContent='Отправка запроса…'; bar.style.width='3%'; detail.textContent='';
|
||||
var xhr=new XMLHttpRequest(); xhr.open('POST', form.action);
|
||||
var file=form.querySelector('input[type=file]');
|
||||
if(file && file.files.length){
|
||||
xhr.upload.onprogress=function(e){ if(e.lengthComputable){ stageEl.textContent='Загрузка файла на сервер';
|
||||
bar.style.width=Math.max(3,Math.round(e.loaded/e.total*25))+'%'; detail.textContent=mb(e.loaded)+' из '+mb(e.total); } };
|
||||
}
|
||||
xhr.onloadend=function(){ postDone=true; };
|
||||
xhr.send(new FormData(form));
|
||||
function poll(){
|
||||
fetch('/admin/setup/update/progress',{cache:'no-store',credentials:'same-origin'}).then(function(r){ if(!r.ok) throw 0; return r.json(); }).then(function(p){
|
||||
if(startVer===null){ startVer=p.version; seq0=p.seq; }
|
||||
if(p.target) target=p.target;
|
||||
if(lost){ // bj вернулся после перезапуска
|
||||
if(!target || p.version===target || p.version!==startVer){ finish('Готово: bj перезапущен, работает версия '+p.version+'.', true); detail.textContent=''; return; }
|
||||
}
|
||||
var fresh = p.seq!==seq0 || seenActive || postDone;
|
||||
if(p.active){ seenActive=true;
|
||||
var steps=p.steps||4, base=(p.step-1)/steps*100, part=100/steps;
|
||||
if(p.total>0 && p.step===2){ base+=part*Math.min(1,p.done/p.total); detail.textContent='Скачано '+mb(p.done)+' из '+mb(p.total); }
|
||||
else if(p.step===3){ detail.textContent='Обычно несколько секунд; при обновлении ядра страница ненадолго пропадёт — это нормально.'; }
|
||||
bar.style.width=Math.max(5,Math.round(base))+'%';
|
||||
stageEl.textContent='Шаг '+p.step+' из '+steps+': '+p.stage;
|
||||
} else if(p.finished && fresh){
|
||||
if(p.error){ finish('Обновление не выполнено', false); detail.textContent=p.error; return; }
|
||||
if(target && p.version!==target){ stageEl.textContent='Шаг 4 из 4: перезапуск bj'; bar.style.width='90%'; detail.textContent='Ждём, пока bj поднимется с новой версией…'; }
|
||||
else { finish('Готово', true); detail.textContent=p.result||''; return; }
|
||||
} else if(postDone && Date.now()-t0>4000 && !seenActive){
|
||||
finish('Готово', true); detail.textContent='Результат — в строке «Последняя проверка».'; return;
|
||||
}
|
||||
setTimeout(poll, 800);
|
||||
}).catch(function(){
|
||||
lost=true; stageEl.textContent='Шаг 4 из 4: перезапуск bj'; bar.style.width='92%';
|
||||
detail.textContent='bj перезапускается с новой версией — страница вернётся сама.';
|
||||
setTimeout(poll, 1500);
|
||||
});
|
||||
}
|
||||
setTimeout(poll, 400);
|
||||
}
|
||||
document.addEventListener('submit', function(e){
|
||||
var f=e.target; if(!f || !f.action || e.defaultPrevented) return;
|
||||
if(!/\/admin\/setup\/update\/(apply|upload)$/.test(new URL(f.action, location.href).pathname)) return;
|
||||
e.preventDefault(); run(f);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user