feat(update): независимые обновления компонентов bj
- internal/release/components.go: список компонентов (ядро bj-server, криптосервис, bj-lk, bj-npr, bj-quikexport) с путём и службой, установленные версии в components.json, расчёт обновлений по версии каждого файла и sha256 (без отката, модули — только если уже установлены на сервере) - cmd/bj-component-install: root-установщик — сам перепроверяет подпись манифеста ключом /etc/bj/update.pub и sha256, копия прежнего файла, перезапуск только затронутых служб - updater: bj-server (без прав root) скачивает в update-staging и передаёт установку root-воркеру (действие component-install); установка в фоне - настройки: таблица компонентов с версиями, «Обновить» и «Обновить всё» - bj-release: версия файла из <файл>.version; bj-keymedia-worker: component-install Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,8 @@ type UpdateSettings struct {
|
||||
LastResult string `json:"last_result"` // текст результата проверки
|
||||
Available string `json:"available_version"` // доступная версия (если новее)
|
||||
Notes string `json:"notes,omitempty"` // заметки доступного релиза
|
||||
// Components — версии компонентов в последнем проверенном релизе (имя → версия).
|
||||
Components map[string]string `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
// NewsSettings — лента новостей (события системы, окна техработ, обновления
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -291,7 +292,9 @@ type SetupData struct {
|
||||
ReadyCount int
|
||||
TotalCount int
|
||||
Certificates []cryptocli.Certificate
|
||||
CurrentVersion string // версия bj-server (для раздела «Обновления»)
|
||||
CurrentVersion string // версия bj-server (для раздела «Обновления»)
|
||||
Components []release.ComponentState // компоненты и их версии (раздел «Обновления»)
|
||||
UpdatesAvailable bool
|
||||
License LicenseStatus // статус лицензии
|
||||
Health []HealthStatus // здоровье фоновых компонентов
|
||||
AdvancedUnlocked bool // открыты ли перспективные модули (QUIK, Fancy)
|
||||
@@ -338,8 +341,11 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
|
||||
ready++
|
||||
}
|
||||
}
|
||||
comps, anyUpdate := updateComponentRows(h.rc)
|
||||
data := SetupData{
|
||||
page: nowPage("Настройка", "setup"),
|
||||
Components: comps,
|
||||
UpdatesAvailable: anyUpdate,
|
||||
Settings: s,
|
||||
Readiness: r,
|
||||
ReadyCount: ready,
|
||||
@@ -1225,11 +1231,27 @@ func (h *setupHandlers) applyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := NewUpdater(h.rc).ApplyUpdate(r.Context()); err != nil {
|
||||
setupFlash(w, r, "Обновление не применено: "+err.Error())
|
||||
return
|
||||
_ = r.ParseForm()
|
||||
names := r.Form["component"]
|
||||
rc := h.rc
|
||||
// В фоне: при обновлении ядра bj-server перезапустится раньше, чем придёт ответ установщика.
|
||||
go func() {
|
||||
msg, err := NewUpdater(rc).ApplyUpdate(context.Background(), names)
|
||||
cfg := rc.Snapshot().Update
|
||||
if err != nil {
|
||||
msg = "Обновление не применено: " + err.Error()
|
||||
} else {
|
||||
msg = "Обновление: " + msg
|
||||
}
|
||||
log.Printf("lk-gateway: %s", msg)
|
||||
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
|
||||
_ = rc.SaveUpdateSettings(cfg)
|
||||
}()
|
||||
what := "всех компонентов с новой версией"
|
||||
if len(names) > 0 {
|
||||
what = strings.Join(names, ", ")
|
||||
}
|
||||
setupFlash(w, r, "Обновление скачано и применено — bj-server перезапускается, обновите страницу через 10 секунд")
|
||||
setupFlash(w, r, "Обновление "+what+" запущено: файлы скачиваются и проверяются, затем перезапускаются службы. Итог — в строке «Последняя проверка» через минуту; при обновлении ядра страница временно не отвечает.")
|
||||
}
|
||||
|
||||
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
|
||||
|
||||
+146
-76
@@ -1,10 +1,21 @@
|
||||
package lkgateway
|
||||
|
||||
// Обновления bj по компонентам (internal/release/components.go). У каждого компонента своя версия в
|
||||
// манифесте артефактории: ядро, криптосервис, ЛК клиента, НПР, экспорт QUIK обновляются независимо.
|
||||
//
|
||||
// bj-server работает без прав root, поэтому сам только проверяет манифест, скачивает выбранные
|
||||
// файлы в /var/lib/bj/update-staging и передаёт установку root-воркеру (действие
|
||||
// component-install → /usr/local/sbin/bj-component-install). Установщик заново проверяет подпись и
|
||||
// sha256, ставит файлы и перезапускает службы; при обновлении ядра bj-server перезапустится сам.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
@@ -17,32 +28,25 @@ var BuildVersion = "0.1.0"
|
||||
|
||||
// DefaultUpdatePublicKey — публичный ключ артефактории, зашитый в релиз.
|
||||
// Пустой в исходниках; подставляется при официальной сборке. Если задан в
|
||||
// настройках (UpdateSettings.PublicKey) — приоритет у настроек.
|
||||
// настройках (UpdateSettings.PublicKey) — приоритет у настроек (для проверки
|
||||
// в bj; root-установщик верит только /etc/bj/update.pub).
|
||||
var DefaultUpdatePublicKey = ""
|
||||
|
||||
// installPaths — куда устанавливать артефакты по логическому имени.
|
||||
// Файлы не из этого списка при авто-обновлении пропускаются (скрипты/SQL
|
||||
// обновляются отдельно, не на лету).
|
||||
var installPaths = map[string]string{
|
||||
"bj-server": "/opt/bj/bj-server",
|
||||
"crypto-service.jar": "/opt/bj/crypto-service.jar",
|
||||
}
|
||||
const updateStagingDir = "/var/lib/bj/update-staging"
|
||||
|
||||
// Updater — авто-обновление bj-server из артефактории (работает поверх rc).
|
||||
type Updater struct{ rc *RuntimeConfig }
|
||||
|
||||
// NewUpdater создаёт Updater на текущем runtime-конфиге.
|
||||
func NewUpdater(rc *RuntimeConfig) *Updater { return &Updater{rc: rc} }
|
||||
|
||||
// UpdateStatus — сводка для UI/handler.
|
||||
type UpdateStatus struct {
|
||||
Configured bool
|
||||
CurrentVersion string
|
||||
Available string
|
||||
HasUpdate bool
|
||||
Available string // версия релиза
|
||||
HasUpdate bool // есть что обновить хотя бы в одном компоненте
|
||||
Channel string
|
||||
Notes string
|
||||
Message string
|
||||
Components []release.ComponentState
|
||||
}
|
||||
|
||||
func (u *Updater) updateClient() (*release.Client, error) {
|
||||
@@ -61,8 +65,7 @@ func (u *Updater) updateClient() (*release.Client, error) {
|
||||
return release.NewClient(cfg.BaseURL, channel, pub)
|
||||
}
|
||||
|
||||
// CheckForUpdate скачивает манифест, проверяет подпись, сравнивает версии и
|
||||
// сохраняет результат в настройки. Возвращает сводку.
|
||||
// CheckForUpdate — манифест и состояние каждого компонента.
|
||||
func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||
st := UpdateStatus{CurrentVersion: BuildVersion, Channel: u.rc.Snapshot().Update.Channel}
|
||||
cl, err := u.updateClient()
|
||||
@@ -75,92 +78,167 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
|
||||
m, err := cl.FetchManifest(ctx)
|
||||
if err != nil {
|
||||
st.Message = "проверка не удалась: " + err.Error()
|
||||
u.saveCheckResult(st)
|
||||
u.saveCheckResult(st, nil)
|
||||
return st, err
|
||||
}
|
||||
st.Available = m.Version
|
||||
st.Notes = m.Notes
|
||||
st.HasUpdate = release.IsNewer(m.Version, BuildVersion)
|
||||
if st.HasUpdate {
|
||||
st.Message = fmt.Sprintf("доступна версия %s (текущая %s)", m.Version, BuildVersion)
|
||||
} else {
|
||||
st.Message = "установлена актуальная версия " + BuildVersion
|
||||
st.Available, st.Notes = m.Version, m.Notes
|
||||
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||
var upd []string
|
||||
avail := map[string]string{}
|
||||
for _, c := range st.Components {
|
||||
if c.Available != "" {
|
||||
avail[c.Name] = c.Available
|
||||
}
|
||||
if c.Update {
|
||||
upd = append(upd, c.Name+" "+c.Available)
|
||||
}
|
||||
}
|
||||
u.saveCheckResult(st)
|
||||
st.HasUpdate = len(upd) > 0
|
||||
if st.HasUpdate {
|
||||
st.Message = "доступны обновления: " + strings.Join(upd, ", ")
|
||||
} else {
|
||||
st.Message = "все установленные компоненты актуальны (релиз " + m.Version + ")"
|
||||
}
|
||||
u.saveCheckResult(st, avail)
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (u *Updater) saveCheckResult(st UpdateStatus) {
|
||||
func (u *Updater) saveCheckResult(st UpdateStatus, avail map[string]string) {
|
||||
cfg := u.rc.Snapshot().Update
|
||||
cfg.LastCheck = time.Now().UTC()
|
||||
cfg.LastResult = st.Message
|
||||
cfg.Available = st.Available
|
||||
cfg.Notes = st.Notes
|
||||
if avail != nil {
|
||||
cfg.Components = avail
|
||||
}
|
||||
if err := u.rc.SaveUpdateSettings(cfg); err != nil {
|
||||
log.Printf("lk-gateway: сохранение результата проверки обновления: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyUpdate скачивает обновлённые артефакты (с проверкой подписи манифеста
|
||||
// и sha256 каждого файла), атомарно заменяет бинари и завершает процесс с
|
||||
// ненулевым кодом — systemd (Restart=on-failure) поднимает новую версию.
|
||||
func (u *Updater) ApplyUpdate(ctx context.Context) error {
|
||||
// Гейт лицензией: если лицензирование включено — требуется валидная
|
||||
// лицензия с фичей updates. Без лицензирования (открытый режим) — пропускаем.
|
||||
// ApplyUpdate — скачать выбранные компоненты (names пусто — все, для которых есть обновление) и
|
||||
// передать установку root-установщику. Если среди них ядро, bj-server перезапустится до ответа.
|
||||
func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, error) {
|
||||
if licensingEnabled(u.rc) {
|
||||
ls := licenseStatus(u.rc)
|
||||
if !ls.Valid {
|
||||
return fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
|
||||
return "", fmt.Errorf("обновления заблокированы — лицензия: %s", ls.Message)
|
||||
}
|
||||
if !ls.AllowsUpdates {
|
||||
return fmt.Errorf("обновления не входят в план %q", ls.Plan)
|
||||
return "", fmt.Errorf("обновления не входят в план %q", ls.Plan)
|
||||
}
|
||||
}
|
||||
cl, err := u.updateClient()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
m, err := cl.FetchManifest(ctx)
|
||||
sm, m, err := cl.FetchSigned(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("манифест: %w", err)
|
||||
return "", fmt.Errorf("манифест: %w", err)
|
||||
}
|
||||
if !release.IsNewer(m.Version, BuildVersion) {
|
||||
return fmt.Errorf("обновление не требуется (текущая %s, доступна %s)", BuildVersion, m.Version)
|
||||
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||
want := map[string]bool{}
|
||||
for _, n := range names {
|
||||
want[n] = true
|
||||
}
|
||||
var selected []release.ComponentState
|
||||
for _, c := range plan {
|
||||
switch {
|
||||
case len(want) > 0 && !want[c.Name]:
|
||||
case c.Update:
|
||||
selected = append(selected, c)
|
||||
case want[c.Name]:
|
||||
return "", fmt.Errorf("%s: обновление не требуется (установлена %s, в релизе %s)", c.Title, pickVersion(c.Installed), pickVersion(c.Available))
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return "", fmt.Errorf("нечего обновлять: все установленные компоненты актуальны (релиз %s)", m.Version)
|
||||
}
|
||||
|
||||
updated := 0
|
||||
for _, a := range m.Artifacts {
|
||||
dst, ok := installPaths[a.Name]
|
||||
if !ok {
|
||||
continue // скрипты/SQL не обновляем на лету
|
||||
}
|
||||
dir := dirOf(dst)
|
||||
path, err := cl.DownloadArtifact(ctx, a, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("скачивание %s: %w", a.Name, err)
|
||||
}
|
||||
// DownloadArtifact кладёт файл под именем a.File; если целевое имя
|
||||
// иное — переименуем атомарно.
|
||||
if path != dst {
|
||||
if err := os.Rename(path, dst); err != nil {
|
||||
return fmt.Errorf("установка %s: %w", a.Name, err)
|
||||
cleanupStaging(24 * time.Hour)
|
||||
staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano()))
|
||||
if err := os.MkdirAll(staging, 0o750); err != nil {
|
||||
return "", fmt.Errorf("каталог подготовки: %w", err)
|
||||
}
|
||||
b, _ := json.Marshal(sm)
|
||||
if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var list []string
|
||||
var req struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
for _, c := range selected {
|
||||
for _, a := range m.Artifacts {
|
||||
if a.Name == c.Name {
|
||||
if _, err := cl.DownloadArtifact(ctx, a, staging); err != nil {
|
||||
return "", fmt.Errorf("скачивание %s: %w", c.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("lk-gateway: обновлён %s → %s (%s)", a.Name, dst, m.Version)
|
||||
updated++
|
||||
req.Names = append(req.Names, c.Name)
|
||||
list = append(list, c.Name+" "+c.Available)
|
||||
}
|
||||
if updated == 0 {
|
||||
return fmt.Errorf("в манифесте %s нет обновляемых бинарей", m.Version)
|
||||
rb, _ := json.Marshal(req)
|
||||
if err := os.WriteFile(filepath.Join(staging, "request.json"), rb, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
log.Printf("lk-gateway: обновление компонентов: %s — передано root-установщику", strings.Join(list, ", "))
|
||||
wctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
res, err := runKeymediaOp(wctx, "component-install", map[string]string{"staging": staging})
|
||||
_ = os.RemoveAll(staging)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("установка: %w", err)
|
||||
}
|
||||
msg := "установлено: " + strings.Join(list, ", ")
|
||||
if r, ok := res["restarted"].([]any); ok && len(r) > 0 {
|
||||
var units []string
|
||||
for _, x := range r {
|
||||
units = append(units, fmt.Sprint(x))
|
||||
}
|
||||
msg += "; перезапущены службы: " + strings.Join(units, ", ")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
log.Printf("lk-gateway: обновление до %s применено (%d файлов), перезапуск через systemd…", m.Version, updated)
|
||||
// Завершаемся с ненулевым кодом — systemd Restart=on-failure поднимет
|
||||
// новый бинарь. Даём пару секунд на флаш логов/ответа.
|
||||
go func() {
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
os.Exit(42)
|
||||
}()
|
||||
return nil
|
||||
// updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории:
|
||||
// установленное на этом сервере и версии из последней проверки.
|
||||
func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
|
||||
rows := release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion)
|
||||
avail := rc.Snapshot().Update.Components
|
||||
var out []release.ComponentState
|
||||
any := false
|
||||
for _, r := range rows {
|
||||
r.Available = avail[r.Name]
|
||||
r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed))
|
||||
if r.Present || r.Core || r.Available != "" {
|
||||
out = append(out, r)
|
||||
any = any || r.Update
|
||||
}
|
||||
}
|
||||
return out, any
|
||||
}
|
||||
|
||||
func pickVersion(v string) string {
|
||||
if v == "" {
|
||||
return "неизвестна"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// cleanupStaging — удалить брошенные каталоги подготовки (например, если bj перезапустился при обновлении ядра).
|
||||
func cleanupStaging(olderThan time.Duration) {
|
||||
entries, err := os.ReadDir(updateStagingDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if info, err := e.Info(); err == nil && e.IsDir() && time.Since(info.ModTime()) > olderThan {
|
||||
_ = os.RemoveAll(filepath.Join(updateStagingDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateLoop — фоновая авто-проверка обновлений (если включена).
|
||||
@@ -174,7 +252,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
if st, err := u.CheckForUpdate(cctx); err == nil && st.HasUpdate {
|
||||
log.Printf("lk-gateway: доступно обновление %s (текущая %s)", st.Available, st.CurrentVersion)
|
||||
log.Printf("lk-gateway: %s", st.Message)
|
||||
}
|
||||
}
|
||||
// первая проверка через минуту после старта
|
||||
@@ -182,6 +260,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Minute):
|
||||
cleanupStaging(time.Hour)
|
||||
check()
|
||||
}
|
||||
for {
|
||||
@@ -193,12 +272,3 @@ func (u *Updater) updateLoop(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dirOf(path string) string {
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '/' {
|
||||
return path[:i]
|
||||
}
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
@@ -922,21 +922,26 @@
|
||||
<h1>Обновления</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Версия bj-server</h2>
|
||||
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-top:6px">
|
||||
<span class="stat-value" style="font-size:20px">{{.CurrentVersion}}</span>
|
||||
{{if .Settings.Update.Available}}
|
||||
{{if ne .Settings.Update.Available .CurrentVersion}}
|
||||
<span class="hero-status warn">● Доступна {{.Settings.Update.Available}}</span>
|
||||
<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Скачать и установить {{.Settings.Update.Available}}? bj-server перезапустится.');">
|
||||
<button type="submit" class="btn btn-ok">⬆ Установить {{.Settings.Update.Available}}</button>
|
||||
</form>
|
||||
{{else}}<span class="hero-status ok">● Актуальная версия</span>{{end}}
|
||||
{{end}}
|
||||
<form method="post" action="/admin/setup/update/check" style="margin:0"><button type="submit" class="btn btn-secondary">Проверить обновления</button></form>
|
||||
<h2>Компоненты</h2>
|
||||
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.</p>
|
||||
<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>
|
||||
{{range .Components}}<tr>
|
||||
<td><strong>{{.Title}}</strong><div class="muted" style="font-size:11px"><code>{{.Name}}</code></div></td>
|
||||
<td>{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}<span class="muted">неизвестна</span>{{end}}{{else}}<span class="muted">не установлен</span>{{end}}</td>
|
||||
<td>{{if .Available}}{{.Available}}{{if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
|
||||
<td><code style="font-size:11px">{{.Unit}}</code></td>
|
||||
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить компонент до {{.Available}}? Его служба перезапустится.');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">Обновить</button></form>{{end}}</td>
|
||||
</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>
|
||||
</div>
|
||||
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового: {{.Settings.Update.Notes}}</p>{{end}}
|
||||
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}} — {{.Settings.Update.LastResult}}</p>{{end}}
|
||||
{{if .Settings.Update.Notes}}<p class="muted" style="margin-top:10px">Что нового в релизе {{.Settings.Update.Available}}: {{.Settings.Update.Notes}}</p>{{end}}
|
||||
{{if not .Settings.Update.LastCheck.IsZero}}<p class="muted" style="margin-top:6px;font-size:12px">Последняя проверка: {{.Settings.Update.LastCheck.Format "02.01.2006 15:04"}}. {{.Settings.Update.LastResult}}</p>{{end}}
|
||||
{{if and .License.Present (not .License.AllowsUpdates)}}<p class="muted" style="margin-top:6px;font-size:12px;color:var(--warn)">Текущий план «{{.License.Plan}}» не включает обновления.</p>{{end}}
|
||||
</div>
|
||||
|
||||
@@ -946,7 +951,7 @@
|
||||
<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://updates.example.com">
|
||||
<label>Канal</label>
|
||||
<label>Канал</label>
|
||||
<select name="channel">
|
||||
<option value="stable" {{if eq .Settings.Update.Channel "stable"}}selected{{end}}>stable — стабильный</option>
|
||||
<option value="beta" {{if eq .Settings.Update.Channel "beta"}}selected{{end}}>beta — предварительный</option>
|
||||
|
||||
Reference in New Issue
Block a user