«Обновления» — одна карточка состояния и одна кнопка; проверка при открытии раздела; «Службы» — в «Диагностике»

- Сверху: «Установлены последние версии» (когда проверено) или «Доступно обновление X — Установить обновление» (всё сразу), или «Проверить не удалось»; кнопка «Проверить сейчас».
- При открытии раздела bj проверяет обновления сам, если последней проверке больше 10 минут (и не чаще раза в 10 минут из браузера — без зацикливания, если источник не настроен); check отвечает JSON для экрана.
- «Компоненты и версии» (обновить один компонент) и «Расширенные настройки» (обновление из файла, источник и канал, ключ, автопроверка) — под раскрытием.
- «Службы» с перезапуском перенесены в «Диагностику»; подсказка в письме о службе — туда же.

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 08:19:34 +03:00
parent 56068af897
commit 6b490986e4
4 changed files with 87 additions and 30 deletions
+8
View File
@@ -1581,6 +1581,14 @@ func (h *setupHandlers) checkUpdate(w http.ResponseWriter, r *http.Request) {
return
}
st, err := NewUpdater(h.rc).CheckForUpdate(r.Context())
if r.Header.Get("X-BJ-Ajax") != "" { // раздел «Обновления» проверяет сам при открытии
msg := st.Message
if err != nil {
msg = err.Error()
}
jsonOut(w, map[string]any{"ok": err == nil, "has_update": st.HasUpdate, "available": st.Available, "msg": msg})
return
}
if err != nil {
setupFlash(w, r, "Проверка обновлений: "+err.Error())
return
+1 -1
View File
@@ -61,7 +61,7 @@ func watchedUnits(rc *RuntimeConfig) []watchedUnit {
st += ", с " + v.Since
}
out = append(out, watchedUnit{Key: v.Unit, Title: v.Title, Unit: v.Unit, OK: v.OK(), State: st,
Hint: "Перезапустить — Настройка → Обновления → «Службы», кнопка «Перезапустить»; если не поднимается — journalctl -u " + v.Unit + "."})
Hint: "Перезапустить — Настройка → Диагностика → «Службы», кнопка «Перезапустить»; если не поднимается — journalctl -u " + v.Unit + "."})
}
// служба ключей — когда она нужна: включена в автозапуск (bj включает её при установке ключа) или уже работала;
// на свежем сервере до установки ключа её может и не быть — это не сбой
@@ -803,6 +803,22 @@
<section class="settings-section" id="sec-tests">
<h1>Диагностика</h1>
<div class="card" id="sec-units">
<h2><span class="dot {{$bad := 0}}{{range .Units}}{{if not .OK}}{{$bad = 1}}{{end}}{{end}}{{if eq $bad 1}}err{{else}}ok{{end}}"></span>Службы</h2>
<p class="muted">Ядро и модули работают отдельными службами: если служба модуля не запущена, его раздел в bj отвечает ошибкой связи. Здесь видно состояние и можно перезапустить службу, не заходя на сервер.</p>
<div class="tw" style="margin-top:8px"><table>
<thead><tr><th>Служба</th><th>Что делает</th><th>Состояние</th><th>С</th><th></th></tr></thead>
<tbody>{{range .Units}}<tr>
<td style="white-space:nowrap"><code>{{.Unit}}</code></td>
<td>{{.Title}}</td>
<td>{{if .OK}}<span class="badge ok">работает</span>{{else}}<span class="badge err">{{.State}}{{with .Sub}} / {{.}}{{end}}{{with .Result}} ({{.}}){{end}}</span>{{end}}</td>
<td class="muted">{{if .Since}}{{.Since}}{{else}}—{{end}}</td>
<td style="white-space:nowrap"><form method="post" action="/admin/setup/units/restart" style="margin:0" onsubmit="return confirm('{{if .Core}}Перезапустить ядро? Страница настроек на несколько секунд станет недоступна.{{else}}Перезапустить службу {{.Unit}}?{{end}}');">
<input type="hidden" name="section" value="sec-units"><input type="hidden" name="unit" value="{{.Unit}}">
<button type="submit" class="btn btn-secondary">Перезапустить</button></form></td>
</tr>{{end}}</tbody></table></div>
</div>
<div class="card">
<h2>Здоровье сервиса</h2>
<p class="muted">Фоновые компоненты под самоконтролем: при сбое перезапускаются автоматически, при зависании — перезапуск всего сервиса и запись в «Новости». Ниже — живой статус.</p>
@@ -903,25 +919,40 @@
<h1>Обновления</h1>
{{template "verline" (.Versions.Get "bj-server")}}
<div class="card" id="sec-units">
<h2><span class="dot {{$bad := 0}}{{range .Units}}{{if not .OK}}{{$bad = 1}}{{end}}{{end}}{{if eq $bad 1}}err{{else}}ok{{end}}"></span>Службы</h2>
<p class="muted">Ядро и модули работают отдельными службами: если служба модуля не запущена, его раздел в bj отвечает ошибкой связи. Здесь видно состояние и можно перезапустить службу, не заходя на сервер.</p>
<div class="tw" style="margin-top:8px"><table>
<thead><tr><th>Служба</th><th>Что делает</th><th>Состояние</th><th>С</th><th></th></tr></thead>
<tbody>{{range .Units}}<tr>
<td><code>{{.Unit}}</code></td>
<td>{{.Title}}</td>
<td>{{if .OK}}<span class="badge ok">работает</span>{{else}}<span class="badge err">{{.State}}{{with .Sub}} / {{.}}{{end}}{{with .Result}} ({{.}}){{end}}</span>{{end}}</td>
<td class="muted">{{if .Since}}{{.Since}}{{else}}—{{end}}</td>
<td style="white-space:nowrap"><form method="post" action="/admin/setup/units/restart" style="margin:0" onsubmit="return confirm('{{if .Core}}Перезапустить ядро? Страница настроек на несколько секунд станет недоступна.{{else}}Перезапустить службу {{.Unit}}?{{end}}');">
<input type="hidden" name="section" value="sec-units"><input type="hidden" name="unit" value="{{.Unit}}">
<button type="submit" class="btn btn-secondary">Перезапустить</button></form></td>
</tr>{{end}}</tbody></table></div>
<div class="card" id="upd-status" data-last="{{if not .Settings.Update.LastCheck.IsZero}}{{.Settings.Update.LastCheck.Unix}}{{else}}0{{end}}" data-has="{{if .UpdatesAvailable}}1{{else}}0{{end}}">
{{if .UpdatesAvailable}}
<div class="stc stc-warn"><span class="stc-ic"></span><div class="stc-b"><b>Доступно обновление{{with .Settings.Update.Available}} {{.}}{{end}}</b><small>{{if .Settings.Update.Notes}}{{.Settings.Update.Notes}}{{else}}новые версии компонентов — список ниже, в «Компоненты и версии»{{end}}</small></div>
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Установить обновление? Службы, которые обновляются, перезапустятся на несколько секунд.');"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn">Установить обновление</button></form></div>
{{else if and (not .Settings.Update.LastCheck.IsZero) .UpdateFailed}}
<div class="stc stc-err"><span class="stc-ic"></span><div class="stc-b"><b>Проверить обновления не удалось</b><small>{{.Settings.Update.LastResult}} · {{msk .Settings.Update.LastCheck}}</small></div>
<button type="button" class="btn btn-secondary" data-upd-check>Проверить ещё раз</button></div>
{{else}}
<div class="stc stc-ok"><span class="stc-ic"></span><div class="stc-b"><b>Установлены последние версии</b><small id="upd-when">{{if .Settings.Update.LastCheck.IsZero}}ещё не проверялось{{else}}проверено {{msk .Settings.Update.LastCheck}}{{end}}{{if .Settings.Update.AutoCheck}} · bj проверяет сам каждые 6 часов{{end}}</small></div>
<button type="button" class="btn btn-secondary" data-upd-check>Проверить сейчас</button></div>
{{end}}
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">В лицензии нет модуля «Обновления».</p>{{end}}
</div>
<script>(function(){
var box=document.getElementById('upd-status'), sec=document.getElementById('sec-update'), busy=false;
function check(force){
if(busy) return; var last=+box.dataset.last||0, now=Date.now()/1000, tried=0;
try{ tried=+sessionStorage.getItem('bj-upd-auto')||0; }catch(e){}
if(!force && (now-last<600 || now-tried<600)) return; // проверяли меньше 10 минут назад (или уже пробовали сами)
try{ sessionStorage.setItem('bj-upd-auto',String(Math.floor(now))); }catch(e){}
busy=true; var b=box.querySelector('.stc'); if(b){ b.className='stc stc-run'; var t=b.querySelector('b'); if(t) t.textContent='Проверяю обновления…'; }
fetch('/admin/setup/update/check',{method:'POST',headers:{'X-BJ-Ajax':'1'},cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
box.dataset.last=String(Math.floor(Date.now()/1000));
location.reload(); // состояние компонентов — с сервера
}).catch(function(){ busy=false; location.reload(); });
}
box.addEventListener('click',function(e){ if(e.target.closest('[data-upd-check]')) check(true); });
document.querySelectorAll('.settings-nav [data-sec="update"]').forEach(function(b){ b.addEventListener('click',function(){ setTimeout(function(){ check(false); },0); }); });
window.addEventListener('load',function(){ if(sec.classList.contains('active')) check(false); });
})();</script>
<div class="card">
<h2>Компоненты</h2>
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся — кроме выделенных из ядра (НПР / УДС, экспорт QUIK): если модуль входит в лицензию, он ставится вместе с обновлением, иначе его раздел перестал бы работать.</p>
<details class="adv-box" {{if .UpdatesAvailable}}open{{end}}>
<summary><span class="sum-h">Компоненты и версии</span><span class="muted">что установлено на этом сервере и что есть в выпуске; можно обновить один компонент</span></summary>
<div style="overflow-x:auto"><table style="margin-top:8px">
<thead><tr><th>Компонент</th><th>Установлена</th><th>Доступна</th><th>Служба</th><th></th></tr></thead>
<tbody>
@@ -934,24 +965,22 @@
</tr>{{end}}
</tbody>
</table></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
{{if .UpdatesAvailable}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить все компоненты, для которых есть новая версия?');"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn btn-ok">Обновить всё</button></form>{{end}}
<form method="post" action="/admin/setup/update/check" style="margin:0"><input type="hidden" name="section" value="sec-update"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
</details>
</div>
<div class="card">
<details class="adv-box">
<summary><span class="sum-h">Расширенные настройки</span><span class="muted">источник и канал обновлений, обновление из файла для сервера без выхода в интернет</span></summary>
<h3 class="adv-h">Обновление из файла</h3>
<p class="muted" style="margin:0 0 8px;font-size:12.5px"><b>Закрытый контур.</b> Если у сервера нет выхода к серверу обновлений, скачайте пакет обновления на портале клиента (lic.zetit.ru → Установка → «Пакет обновления») и загрузите его кнопкой «Обновить из файла». Система сама распознаёт, что внутри — ядро, модули или всё вместе — и ставит в правильном порядке. Модуль, которому нужно более новое ядро, чем установлено, будет пропущен с подсказкой обновить сначала ядро.</p>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<form method="post" action="/admin/setup/update/upload" enctype="multipart/form-data" style="margin:0;display:flex;gap:8px;align-items:center;flex-wrap:wrap" onsubmit="return this.bundle.files.length>0 && confirm('Установить обновление из файла? Подпись пакета будет проверена; поставятся компоненты новее текущих — сначала ядро, затем модули. Службы перезапустятся.');">
<input type="hidden" name="section" value="sec-update">
<input type="file" name="bundle" accept=".zip,application/zip" required style="max-width:260px">
<button type="submit" class="btn btn-secondary" title="Пакет обновления с портала клиента — для сервера без выхода к серверу обновлений">Обновить из файла</button>
</form>
</div>
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового в релизе {{.Settings.Update.Available}}: {{.Settings.Update.Notes}}</p>{{end}}
<p class="muted" style="margin-top:8px;font-size:12px"><b>Закрытый контур.</b> Если у сервера нет выхода к серверу обновлений, скачайте пакет обновления на портале клиента (lic.zetit.ru → Установка → «Пакет обновления») и загрузите его кнопкой «Обновить из файла». Система сама распознаёт, что внутри — ядро, модули или всё вместе — и ставит в правильном порядке. Модуль, которому нужно более новое ядро, чем установлено, будет пропущен с подсказкой обновить сначала ядро.</p>
{{if not .Settings.Update.LastCheck.IsZero}}{{if .UpdateFailed}}<div class="alert alert-warn" style="margin-top:10px">{{.Settings.Update.LastResult}} <span class="muted">({{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}})</span></div>{{else}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}}. {{.Settings.Update.LastResult}}</p>{{end}}{{end}}
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">В лицензии нет модуля «Обновления».</p>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if .Settings.Update.BaseURL}}ok{{else}}warn{{end}}"></span>Источник обновлений</h2>
<p class="muted">Обновления раздаёт сервер лицензий по лицензии этой установки: модуль скачивается, только если он входит в лицензию. Каждый релиз проверяется по подписи Ed25519 и контрольным суммам, без действительной подписи установка не выполняется.</p>
</div>
<h3 class="adv-h">Источник обновлений</h3>
<form method="post" action="/admin/setup/update" style="margin-top:12px;display:grid;gap:10px;max-width:680px">
<label>URL артефактории</label>
<input type="text" name="base_url" value="{{.Settings.Update.BaseURL}}" placeholder="https://lic.zetit.ru/updates (по умолчанию)">
@@ -965,6 +994,7 @@
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" name="auto_check" {{if .Settings.Update.AutoCheck}}checked{{end}} style="width:auto"> Проверять автоматически (раз в 6 часов)</label>
<div><button type="submit" class="btn">Сохранить</button></div>
</form>
</details>
</div>
</section>
@@ -378,6 +378,25 @@ label { font-size:13px; font-weight:500; }
.quik-term b{color:var(--text);font-weight:600}
.quik-term input{margin-top:3px;flex:none}
.org-grid{grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,2fr);gap:10px;max-width:none}
/* карточка состояния (обновления и др.): значок, что происходит, действие справа */
.stc{display:flex;gap:14px;align-items:center;padding:14px 18px;border-radius:12px;border:1px solid var(--border);background:var(--card-2)}
.stc-ic{flex:none;position:relative;width:28px;height:28px;border-radius:50%;background:var(--border-strong)}
.stc-b{flex:1;min-width:0}
.stc-b b{display:block;font-size:15px;font-weight:650}
.stc-b small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:2px;overflow-wrap:anywhere}
.stc form, .stc .btn{flex:none}
.stc-ok{background:color-mix(in srgb,var(--ok) 9%,var(--card));border-color:color-mix(in srgb,var(--ok) 40%,var(--border))}
.stc-ok .stc-ic{background:var(--ok)}
.stc-ok .stc-ic::after{content:"";position:absolute;left:50%;top:45%;width:6px;height:12px;border:solid #fff;border-width:0 2.2px 2.2px 0;transform:translate(-50%,-50%) rotate(45deg)}
.stc-warn{background:color-mix(in srgb,var(--accent) 8%,var(--card));border-color:color-mix(in srgb,var(--accent) 40%,var(--border))}
.stc-warn .stc-ic{background:var(--accent)}
.stc-warn .stc-ic::after{content:"↑";position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px}
.stc-err{background:var(--warn-weak);border-color:color-mix(in srgb,var(--warn) 45%,var(--border))}
.stc-err .stc-ic{background:var(--warn)}
.stc-err .stc-ic::after{content:"!";position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px}
.stc-run .stc-ic{background:none;border:2.5px solid var(--accent-weak);border-top-color:var(--accent);box-sizing:border-box;animation:dq-spin .8s linear infinite}
.stc-run .btn, .stc-run form{visibility:hidden}
@media (max-width:600px){.stc{flex-wrap:wrap}}
@media (max-width:700px){.org-grid{grid-template-columns:minmax(0,1fr)}}
@media (max-width:820px) {
.settings { grid-template-columns:1fr; }