Files
Bridge-and-Join-s/internal/lkgateway/admin.go
T
fontvielle 958d777751 feat(lk-gateway): admin setup wizard — конфигурация подсистем через UI + тестовый прогон
Добавлена вкладка «Настройка» в admin-панели lk-gateway. Позволяет
ввести параметры каждой подсистемы прямо через веб-интерфейс,
проверить подключение и запустить тестовую заявку в один клик.

internal/lkgateway/runtimeconfig.go:
- Runtime-конфиг с персистом в JSON (BJ_SETUP_PATH или ~/.bj/setup.json)
- Поля: PostgresDSN, Crypto (provider/socket/jcp_path/license_key),
  NSD (profile/igw_base_url/key_container), LK (callback_url),
  LastTestRun (результат последнего тестового прогона)
- ReadinessSummary() для блока «Готовность системы: X из Y»

internal/lkgateway/setup.go:
- GET /admin/setup — страница настройки
- POST /admin/setup/postgres — DSN + sql.Ping (без pgx-драйвера упадёт
  на «unknown driver postgres», что покажет пользователю)
- POST /admin/setup/crypto — provider/socket/jcp.jar/лицензия, проверка
  существования файла jcp.jar
- POST /admin/setup/nsd — профиль/URL ИШ/контейнер, GET /healthz ИШ
- POST /admin/setup/lk — callback URL + GET /healthz эмулятора/ЛК
- POST /admin/setup/test-run — пробная сквозная заявка с предзаполнением
  (Иванов, 1500 акций Газпрома, ИИС T03), опрос статуса до финального

internal/lkgateway/web/templates/admin_setup.html:
- 4 карточки подсистем со статус-индикаторами (зелёная/красная точка)
- Inline-формы через <details>/<summary>: открыты для не настроенных,
  свёрнуты для уже настроенных
- Карточка «Тестовый прогон» с историей последнего результата
- Прогресс «Готовность системы: X из Y» в верхней части

internal/lkgateway/server.go:
- Server.rc *RuntimeConfig — поднимается при NewServer
- CheckOptions для admin-дашборда теперь берутся из runtime-конфига,
  а не только из ENV — изменения в /admin/setup сразу видны в /admin/
  и /admin/status без перезапуска

В layout.html добавлена nav-ссылка «Настройка», между «Дашборд» и
«Заявки».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 11:30:48 +03:00

190 lines
5.3 KiB
Go

package lkgateway
import (
"embed"
"fmt"
"html/template"
"net/http"
"path"
"strings"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2mcore"
)
//go:embed web/templates/*.html
var templatesFS embed.FS
// admin содержит по одному *template.Template на каждый view (layout +
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
}
func newAdmin() (*admin, error) {
parse := func(content string) (*template.Template, error) {
return template.ParseFS(templatesFS,
"web/templates/layout.html",
"web/templates/"+content)
}
home, err := parse("admin_home.html")
if err != nil {
return nil, fmt.Errorf("parse admin_home: %w", err)
}
claims, err := parse("admin_claims.html")
if err != nil {
return nil, fmt.Errorf("parse admin_claims: %w", err)
}
claim, err := parse("admin_claim.html")
if err != nil {
return nil, fmt.Errorf("parse admin_claim: %w", err)
}
status, err := parse("admin_status.html")
if err != nil {
return nil, fmt.Errorf("parse admin_status: %w", err)
}
setup, err := parse("admin_setup.html")
if err != nil {
return nil, fmt.Errorf("parse admin_setup: %w", err)
}
return &admin{home: home, claims: claims, claim: claim, status: status, setup: setup}, nil
}
// page — общий "конверт" данных для всех шаблонов.
type page struct {
Title string
Active string
Now string
}
// homeData — данные дашборда.
type homeData struct {
page
Status SystemStatus
Counts struct {
Total int
Confirmed int
InProgress int
Failed int
}
Recent []ClaimView
}
// claimsData — данные журнала.
type claimsData struct {
page
Items []ClaimView
}
// claimData — данные карточки.
type claimData struct {
page
Claim ClaimView
}
// statusData — данные страницы статуса.
type statusData struct {
page
Checks []Status
CheckedAt time.Time
}
// RegisterAdmin вешает HTML-маршруты /admin/* на mux. Возвращает admin
// со всеми загруженными шаблонами — вызывающий может прокинуть его в
// registerSetup для добавления вкладки «Настройка».
func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) (*admin, error) {
a, err := newAdmin()
if err != nil {
return nil, err
}
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/admin/")
switch {
case p == "" || p == "index" || p == "home":
a.renderHome(w, r, svc, getOpts())
case p == "claims":
a.renderClaims(w, r, svc)
case strings.HasPrefix(p, "claims/"):
id := strings.TrimPrefix(p, "claims/")
a.renderClaim(w, r, svc, id)
case p == "status":
a.renderStatus(w, r, getOpts())
default:
http.NotFound(w, r)
}
})
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
})
return a, nil
}
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) {
ctx := r.Context()
status := CheckAll(ctx, opts)
recent, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 10})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := homeData{
page: nowPage("Дашборд", "home"),
Status: status,
Recent: recent.Items,
}
full, err := svc.ListClaims(ctx, m2mcore.Filter{Limit: 200})
if err == nil {
for _, c := range full.Items {
data.Counts.Total++
switch c.Status {
case string(m2mcore.StateConfirmed), string(m2mcore.StateAwaitingSUB16), string(m2mcore.StateDone):
data.Counts.Confirmed++
case string(m2mcore.StateRejected), string(m2mcore.StateTimedOut):
data.Counts.Failed++
default:
data.Counts.InProgress++
}
}
}
render(w, a.home, data)
}
func (a *admin) renderClaims(w http.ResponseWriter, r *http.Request, svc *Service) {
pageData, err := svc.ListClaims(r.Context(), m2mcore.Filter{Limit: 200})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
render(w, a.claims, claimsData{page: nowPage("Заявки", "claims"), Items: pageData.Items})
}
func (a *admin) renderClaim(w http.ResponseWriter, r *http.Request, svc *Service, id string) {
id = path.Base(id)
view, err := svc.GetClaim(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
render(w, a.claim, claimData{page: nowPage("Заявка", "claims"), Claim: view})
}
func (a *admin) renderStatus(w http.ResponseWriter, r *http.Request, opts CheckOptions) {
s := CheckAll(r.Context(), opts)
render(w, a.status, statusData{
page: nowPage("Статус", "status"), Checks: s.Checks, CheckedAt: s.CheckedAt,
})
}
func render(w http.ResponseWriter, t *template.Template, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func nowPage(title, active string) page {
return page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")}
}