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 { // пустой Unit у bj-component-install — не служба; пустая строка не должна проходить if c.Unit != "" && 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) }