Настройка: состояние служб ядра и модулей с перезапуском из админки

Модули работают отдельными службами (bj-npr, bj-quikexport, bj-lk), и когда
служба лежит, раздел модуля отвечает ошибкой связи, а администратору
предлагалось идти в консоль за systemctl status. Теперь в разделе «Обновления»
карточка «Службы»: состояние каждой службы, с какого времени, причина отказа
и кнопка «Перезапустить».

Состояние читаем обычным systemctl show — прав не требует. Перезапуск делает
root-демон bj-keymedia (тот же канал, что у установки обновлений) по закрытому
списку служб: чужую службу и сам демон отсюда не тронуть.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
This commit is contained in:
zuevav
2026-09-18 19:28:58 +03:00
parent 510a34b7bc
commit 414227796b
5 changed files with 1164 additions and 0 deletions
+3
View File
@@ -93,6 +93,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
mux.HandleFunc("/admin/setup/lk/relay-php.zip", h.lkPHPRelayDownload)
mux.HandleFunc("/admin/setup/lk/preview", h.lkPreviewSave)
mux.HandleFunc("/admin/setup/lk/data", h.lkDataSave)
mux.HandleFunc("/admin/setup/units/restart", h.unitRestart)
mux.HandleFunc("/admin/setup/lk-integration", h.saveLKIntegration)
mux.HandleFunc("/admin/setup/lk-check", h.checkLKConn)
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
@@ -324,6 +325,7 @@ type SetupData struct {
LKUsersError string
LKPreview LKPreviewStatus // кабинет на этом сервере для просмотра
LKData LKDataView // курсы валют и ежедневный срез стоимости
Units []UnitView // службы bj и её модулей
Versions SystemVersions // версии компонентов bj и внешних систем
Modules map[string]bool // какие лицензируемые модули разрешены (ключ = признак)
Features []LicenseFeatureView // признаки лицензии для карточки
@@ -391,6 +393,7 @@ func (h *setupHandlers) renderSetup(w http.ResponseWriter, req *http.Request, fl
LKAgent: lkAgentStatus(s),
LKPreview: lkPreviewStatus(h.rc, req),
LKData: lkDataView(h.rc),
Units: unitViews(h.rc),
LKKeyMask: lkKeyForPage(s.LK.APIKey),
Versions: systemVersions(h.rc),
Modules: modulesAllowed(h.rc),
+126
View File
@@ -0,0 +1,126 @@
package lkgateway
// units.go — состояние служб bj и её модулей для страницы настроек.
//
// Модули вынесены в отдельные службы (bj-npr, bj-quikexport, bj-lk), и когда одна
// из них лежит, раздел модуля в bj отвечает ошибкой связи. Раньше администратору
// оставалось идти в консоль за systemctl status; здесь он видит состояние всех
// служб и может перезапустить нужную. Читаем состояние обычным systemctl show
// (прав не требует), перезапуск делает root-демон bj-keymedia по закрытому списку.
import (
"context"
"net/http"
"os/exec"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
// UnitView — служба для карточки настроек.
type UnitView struct {
Unit string // bj-npr
Title string // Риск-монитор НПР / УДС
Core bool // ядро (перезапуск рвёт текущую страницу)
State string // active | inactive | failed | unknown
Sub string // running, dead, …
Since string // с какого времени в этом состоянии
Result string // причина отказа (systemd Result)
Present bool // компонент установлен на этом сервере
}
// OK — служба работает.
func (u UnitView) OK() bool { return u.State == "active" && u.Sub == "running" }
// unitState — состояние одной службы (без прав root).
func unitState(unit string) UnitView {
v := UnitView{Unit: unit, State: "unknown"}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "systemctl", "show", unit,
"--property=ActiveState,SubState,ActiveEnterTimestamp,Result").Output()
if err != nil {
return v
}
for _, line := range strings.Split(string(out), "\n") {
k, val, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || val == "" {
continue
}
switch k {
case "ActiveState":
v.State = val
case "SubState":
v.Sub = val
case "Result":
if val != "success" {
v.Result = val
}
case "ActiveEnterTimestamp":
// «Wed 2026-09-16 01:13:56 MSK» — оставляем дату и время
if f := strings.Fields(val); len(f) >= 3 {
v.Since = f[1] + " " + f[2]
}
}
}
return v
}
// unitViews — службы bj на этом сервере: ядро и установленные модули.
func unitViews(rc *RuntimeConfig) []UnitView {
installed := release.LoadInstalled(release.InstalledFile)
var out []UnitView
for _, c := range release.Components {
if c.Unit == "" {
continue
}
_, present := installed[c.Name]
if !present && !c.Core {
continue // модуля на сервере нет — и службы быть не должно
}
v := unitState(c.Unit)
v.Title, v.Core, v.Present = c.Title, c.Core, true
out = append(out, v)
}
return out
}
// restartUnit — перезапуск службы через root-демон (см. bj-keymedia-worker).
func restartUnit(ctx context.Context, unit string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
res, err := runKeymediaOp(cctx, "unit-restart", map[string]string{"extra": unit})
if err != nil {
return "", err
}
if s, ok := res["output"].(string); ok && s != "" {
return s, nil
}
return "перезапущено: " + unit, nil
}
// unitRestart — POST /admin/setup/units/restart: перезапуск службы из настроек.
func (h *setupHandlers) unitRestart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
unit := strings.TrimSpace(r.FormValue("unit"))
known := false
for _, c := range release.Components {
if c.Unit == unit {
known = true
}
}
if !known {
setupFlash(w, r, "Неизвестная служба.")
return
}
msg, err := restartUnit(r.Context(), unit)
if err != nil {
setupFlash(w, r, "Перезапуск "+unit+": "+err.Error())
return
}
setupFlash(w, r, msg)
}
@@ -1073,6 +1073,22 @@ function hbBind(inp,box,list,onPick){
<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>
<div class="card">
<h2>Компоненты</h2>
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся — кроме выделенных из ядра (НПР / УДС, экспорт QUIK): если модуль входит в лицензию, он ставится вместе с обновлением, иначе его раздел перестал бы работать.</p>