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>
This commit is contained in:
@@ -19,7 +19,7 @@ var templatesFS embed.FS
|
||||
// конкретный content-шаблон). Так html/template не путается с несколькими
|
||||
// {{define "content"}} в разных файлах.
|
||||
type admin struct {
|
||||
home, claims, claim, status *template.Template
|
||||
home, claims, claim, status, setup *template.Template
|
||||
}
|
||||
|
||||
func newAdmin() (*admin, error) {
|
||||
@@ -44,7 +44,11 @@ func newAdmin() (*admin, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin_status: %w", err)
|
||||
}
|
||||
return &admin{home: home, claims: claims, claim: claim, status: status}, nil
|
||||
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 — общий "конверт" данных для всех шаблонов.
|
||||
@@ -86,11 +90,13 @@ type statusData struct {
|
||||
CheckedAt time.Time
|
||||
}
|
||||
|
||||
// RegisterAdmin вешает HTML-маршруты /admin/* на mux.
|
||||
func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions) error {
|
||||
// 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 err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -112,7 +118,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, getOpts func() CheckOptions
|
||||
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
})
|
||||
return nil
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *admin) renderHome(w http.ResponseWriter, r *http.Request, svc *Service, opts CheckOptions) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RuntimeConfig — конфигурация подсистем, редактируемая через admin UI
|
||||
// без перезапуска. Сохраняется в JSON-файл (BJ_SETUP_PATH или
|
||||
// ~/.bj/setup.json), грузится при старте.
|
||||
type RuntimeConfig struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
data Settings
|
||||
}
|
||||
|
||||
// Settings — сериализуемое представление настроек.
|
||||
type Settings struct {
|
||||
Postgres PostgresSettings `json:"postgres"`
|
||||
Crypto CryptoSettings `json:"crypto"`
|
||||
NSD NSDSettings `json:"nsd"`
|
||||
LK LKSettings `json:"lk"`
|
||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PostgresSettings — DSN для подключения к БД (M2-шаг-3).
|
||||
type PostgresSettings struct {
|
||||
DSN string `json:"dsn"`
|
||||
}
|
||||
|
||||
// CryptoSettings — путь к JCP, провайдер, лицензионный ключ.
|
||||
type CryptoSettings struct {
|
||||
Provider string `json:"provider"` // "stub" | "cryptopro" | "validata" | "vipnet"
|
||||
SocketPath string `json:"socket_path"` // UDS crypto-service
|
||||
JCPPath string `json:"jcp_path"` // путь до jcp.jar
|
||||
LicenseKey string `json:"license_key"` // лицензионный ключ КриптоПро
|
||||
}
|
||||
|
||||
// NSDSettings — профиль и подключение к ИШ НРД.
|
||||
type NSDSettings struct {
|
||||
Profile string `json:"profile"` // "guest-gost", "test3-gost", ...
|
||||
IGWBaseURL string `json:"igw_base_url"` // http://host:port
|
||||
KeyContainer string `json:"key_container"` // имя контейнера (на стороне ИШ)
|
||||
}
|
||||
|
||||
// LKSettings — настройки callback в ЛК клиента.
|
||||
type LKSettings struct {
|
||||
CallbackURL string `json:"callback_url"`
|
||||
}
|
||||
|
||||
// TestRunResult — результат последнего тестового прогона.
|
||||
type TestRunResult struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt time.Time `json:"finished_at"`
|
||||
ClaimID string `json:"claim_id"`
|
||||
FinalStatus string `json:"final_status"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// NewRuntimeConfig создаёт runtime-конфиг, читая JSON из path (или дефолт).
|
||||
func NewRuntimeConfig(path string) (*RuntimeConfig, error) {
|
||||
if path == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
home = "."
|
||||
}
|
||||
path = filepath.Join(home, ".bj", "setup.json")
|
||||
}
|
||||
rc := &RuntimeConfig{path: path}
|
||||
if err := rc.load(); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
// Гарантируем разумные дефолты.
|
||||
if rc.data.Crypto.Provider == "" {
|
||||
rc.data.Crypto.Provider = "stub"
|
||||
}
|
||||
if rc.data.Crypto.SocketPath == "" {
|
||||
rc.data.Crypto.SocketPath = "/run/bj/crypto.sock"
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
// Snapshot возвращает копию текущих настроек.
|
||||
func (r *RuntimeConfig) Snapshot() Settings {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := r.data
|
||||
if r.data.LastTest != nil {
|
||||
t := *r.data.LastTest
|
||||
out.LastTest = &t
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpdatePostgres сохраняет postgres-настройки.
|
||||
func (r *RuntimeConfig) UpdatePostgres(s PostgresSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.Postgres = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateCrypto сохраняет crypto-настройки.
|
||||
func (r *RuntimeConfig) UpdateCrypto(s CryptoSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.Crypto = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateNSD сохраняет NSD-настройки.
|
||||
func (r *RuntimeConfig) UpdateNSD(s NSDSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.NSD = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// UpdateLK сохраняет LK callback URL.
|
||||
func (r *RuntimeConfig) UpdateLK(s LKSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.LK = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// RecordTestRun сохраняет результат теста.
|
||||
func (r *RuntimeConfig) RecordTestRun(res TestRunResult) error {
|
||||
r.mu.Lock()
|
||||
r.data.LastTest = &res
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// load читает JSON в r.data.
|
||||
func (r *RuntimeConfig) load() error {
|
||||
raw, err := os.ReadFile(r.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, &r.data)
|
||||
}
|
||||
|
||||
// save пишет JSON в r.path атомарно через tmp + rename.
|
||||
func (r *RuntimeConfig) save() error {
|
||||
r.mu.RLock()
|
||||
raw, err := json.MarshalIndent(r.data, "", " ")
|
||||
r.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(r.path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := r.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, r.path)
|
||||
}
|
||||
|
||||
// Readiness — сводная готовность подсистемы.
|
||||
type Readiness struct {
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"` // полностью настроена и проверена
|
||||
Configured bool `json:"configured"` // есть пользовательский конфиг (не stub)
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ReadinessSummary возвращает компактную сводку для UI/admin.
|
||||
func (r *RuntimeConfig) ReadinessSummary() []Readiness {
|
||||
s := r.Snapshot()
|
||||
out := []Readiness{
|
||||
{
|
||||
Name: "postgres",
|
||||
Configured: s.Postgres.DSN != "",
|
||||
Ready: false, // настоящий ping будет в checks.go
|
||||
Message: posMsg(s.Postgres.DSN),
|
||||
},
|
||||
{
|
||||
Name: "crypto-service",
|
||||
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub" && s.Crypto.JCPPath != "",
|
||||
Ready: false,
|
||||
Message: cryptoMsg(s.Crypto),
|
||||
},
|
||||
{
|
||||
Name: "nsd-adapter",
|
||||
Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "",
|
||||
Ready: false,
|
||||
Message: nsdMsg(s.NSD),
|
||||
},
|
||||
{
|
||||
Name: "lk-callback",
|
||||
Configured: s.LK.CallbackURL != "",
|
||||
Ready: false,
|
||||
Message: lkMsg(s.LK),
|
||||
},
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func posMsg(dsn string) string {
|
||||
if dsn == "" {
|
||||
return "DSN не задан — система в режиме in-memory (M2-демо)"
|
||||
}
|
||||
return "DSN настроен: " + maskDSN(dsn)
|
||||
}
|
||||
|
||||
func cryptoMsg(c CryptoSettings) string {
|
||||
if c.Provider == "" || c.Provider == "stub" {
|
||||
return "Криптография не настроена (provider=stub). КриптоПро JCP не подключён."
|
||||
}
|
||||
if c.JCPPath == "" {
|
||||
return "Провайдер " + c.Provider + ", но путь к JCP не задан."
|
||||
}
|
||||
if c.LicenseKey == "" {
|
||||
return "Провайдер " + c.Provider + ", JCP есть, лицензия не введена."
|
||||
}
|
||||
return "Провайдер " + c.Provider + ", JCP подключён, лицензия введена."
|
||||
}
|
||||
|
||||
func nsdMsg(n NSDSettings) string {
|
||||
if n.IGWBaseURL == "" {
|
||||
return "ИШ НРД не настроен — используется mock-режим (Decision через 3 сек)"
|
||||
}
|
||||
if n.Profile == "" {
|
||||
return "URL ИШ задан, но профиль не выбран"
|
||||
}
|
||||
return "Профиль " + n.Profile + ", ИШ " + n.IGWBaseURL
|
||||
}
|
||||
|
||||
func lkMsg(l LKSettings) string {
|
||||
if l.CallbackURL == "" {
|
||||
return "Callback URL не настроен — используется встроенный lk-emulator"
|
||||
}
|
||||
return "Callback URL: " + l.CallbackURL
|
||||
}
|
||||
|
||||
// maskDSN скрывает пароль в DSN для отображения в UI.
|
||||
func maskDSN(dsn string) string {
|
||||
// простая маскировка: ищем :///user:pass@host
|
||||
const sep = "@"
|
||||
if idx := indexAt(dsn, sep); idx > 0 {
|
||||
if colon := lastColonBefore(dsn, idx); colon > 0 && colon < idx {
|
||||
return dsn[:colon+1] + "***" + dsn[idx:]
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func indexAt(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastColonBefore(s string, idx int) int {
|
||||
for i := idx - 1; i >= 0; i-- {
|
||||
if s[i] == ':' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ServerConfig struct {
|
||||
DefaultReceiver m2m.DeponentCode
|
||||
CheckOptions func() CheckOptions
|
||||
MockDecisionDelay time.Duration // 0 = дефолт 3 секунды
|
||||
SetupPath string // путь к JSON-файлу runtime-конфига (пусто = ~/.bj/setup.json)
|
||||
}
|
||||
|
||||
// Server — обвязка HTTP + сервис + workers.
|
||||
@@ -27,6 +28,7 @@ type Server struct {
|
||||
svc *Service
|
||||
mock *mock.Sender
|
||||
store *SeedStore
|
||||
rc *RuntimeConfig
|
||||
mux *http.ServeMux
|
||||
server *http.Server
|
||||
}
|
||||
@@ -51,18 +53,47 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
DefaultReceiver: cfg.DefaultReceiver,
|
||||
})
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterAPI(mux, svc)
|
||||
if cfg.CheckOptions == nil {
|
||||
cfg.CheckOptions = func() CheckOptions {
|
||||
return CheckOptions{Profile: "demo (mock NSD)", CryptoProvider: "stub"}
|
||||
}
|
||||
}
|
||||
if err := RegisterAdmin(mux, svc, cfg.CheckOptions); err != nil {
|
||||
rc, err := NewRuntimeConfig(cfg.SetupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Если runtime-конфиг уже содержит callback URL — применяем его.
|
||||
if s := rc.Snapshot(); s.LK.CallbackURL != "" {
|
||||
svc.callbackURL = s.LK.CallbackURL
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterAPI(mux, svc)
|
||||
|
||||
// CheckOptions берётся из runtime-конфига при каждом запросе на дашборд.
|
||||
checkOpts := func() CheckOptions {
|
||||
s := rc.Snapshot()
|
||||
profile := "demo (mock NSD)"
|
||||
if s.NSD.Profile != "" {
|
||||
profile = s.NSD.Profile
|
||||
}
|
||||
return CheckOptions{
|
||||
PostgresDSN: s.Postgres.DSN,
|
||||
CryptoSocket: s.Crypto.SocketPath,
|
||||
NSDAdapterURL: s.NSD.IGWBaseURL,
|
||||
LKCallbackURL: s.LK.CallbackURL,
|
||||
Profile: profile,
|
||||
CryptoProvider: nonEmpty(s.Crypto.Provider, "stub"),
|
||||
Timeout: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
if cfg.CheckOptions != nil {
|
||||
// Опциональный override (например, из cmd/lk-gateway для override ENV-перетягивания).
|
||||
checkOpts = cfg.CheckOptions
|
||||
}
|
||||
|
||||
adminTpl, err := RegisterAdmin(mux, svc, checkOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerSetup(mux, adminTpl, rc, svc)
|
||||
registerHealth(mux)
|
||||
registerSetCallback(mux, svc)
|
||||
registerSetCallback(mux, svc, rc)
|
||||
registerSeedListing(mux, store)
|
||||
|
||||
return &Server{
|
||||
@@ -70,6 +101,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
svc: svc,
|
||||
mock: sender,
|
||||
store: store,
|
||||
rc: rc,
|
||||
mux: mux,
|
||||
server: &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
@@ -79,6 +111,16 @@ func NewServer(cfg ServerConfig) (*Server, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RuntimeConfig возвращает текущий runtime-конфиг (для тестов).
|
||||
func (s *Server) RuntimeConfig() *RuntimeConfig { return s.rc }
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetCallbackURL обновляет адрес, куда отправлять PATCH callback'и в ЛК.
|
||||
func (s *Server) SetCallbackURL(url string) { s.svc.callbackURL = url }
|
||||
|
||||
@@ -146,8 +188,10 @@ func registerHealth(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
// registerSetCallback — служебный POST /admin/api/callback-url для
|
||||
// эмулятора ЛК, чтобы сообщить gateway свой URL.
|
||||
func registerSetCallback(mux *http.ServeMux, svc *Service) {
|
||||
// эмулятора ЛК, чтобы сообщить gateway свой URL. Если URL уже сохранён
|
||||
// в runtime-конфиге (пользователь явно настроил его через UI), запрос
|
||||
// эмулятора игнорируется — приоритет у явно настроенного.
|
||||
func registerSetCallback(mux *http.ServeMux, svc *Service, rc *RuntimeConfig) {
|
||||
mux.HandleFunc("/admin/api/callback-url", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
@@ -158,6 +202,13 @@ func registerSetCallback(mux *http.ServeMux, svc *Service) {
|
||||
http.Error(w, "url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if rc != nil {
|
||||
if s := rc.Snapshot(); s.LK.CallbackURL != "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("kept-user-configured"))
|
||||
return
|
||||
}
|
||||
}
|
||||
svc.callbackURL = url
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setupHandlers — обработчики /admin/setup/*.
|
||||
type setupHandlers struct {
|
||||
rc *RuntimeConfig
|
||||
tpl *adminTemplates
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// adminTemplates — обёртка, чтобы передать набор шаблонов в setup.
|
||||
type adminTemplates struct {
|
||||
a *admin
|
||||
}
|
||||
|
||||
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
|
||||
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
|
||||
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
|
||||
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
h.renderSetup(w, r, "")
|
||||
})
|
||||
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
|
||||
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
|
||||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
||||
}
|
||||
|
||||
// SetupData — данные для шаблона admin_setup.html.
|
||||
type SetupData struct {
|
||||
page
|
||||
Settings Settings
|
||||
Readiness []Readiness
|
||||
ReadyCount int
|
||||
TotalCount int
|
||||
Flash string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
|
||||
s := h.rc.Snapshot()
|
||||
r := h.rc.ReadinessSummary()
|
||||
ready := 0
|
||||
for _, x := range r {
|
||||
if x.Configured {
|
||||
ready++
|
||||
}
|
||||
}
|
||||
data := SetupData{
|
||||
page: nowPage("Настройка", "setup"),
|
||||
Settings: s,
|
||||
Readiness: r,
|
||||
ReadyCount: ready,
|
||||
TotalCount: len(r),
|
||||
Flash: flash,
|
||||
}
|
||||
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
||||
data.Error = errVal
|
||||
}
|
||||
render(w, h.tpl.a.setup, data)
|
||||
}
|
||||
|
||||
func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
dsn := strings.TrimSpace(r.FormValue("dsn"))
|
||||
if dsn != "" {
|
||||
// Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше
|
||||
// просто ошибка — это нормально, важно показать что DSN сохранён).
|
||||
if err := tryPingPostgres(dsn); err != nil {
|
||||
setupFlash(w, r, "postgres: тест соединения упал: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
|
||||
setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "PostgreSQL настройки сохранены")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := CryptoSettings{
|
||||
Provider: strings.TrimSpace(r.FormValue("provider")),
|
||||
SocketPath: strings.TrimSpace(r.FormValue("socket_path")),
|
||||
JCPPath: strings.TrimSpace(r.FormValue("jcp_path")),
|
||||
LicenseKey: strings.TrimSpace(r.FormValue("license_key")),
|
||||
}
|
||||
if s.Provider == "" {
|
||||
s.Provider = "stub"
|
||||
}
|
||||
if s.SocketPath == "" {
|
||||
s.SocketPath = "/run/bj/crypto.sock"
|
||||
}
|
||||
// Если указан JCP-путь — проверим что файл существует.
|
||||
if s.JCPPath != "" {
|
||||
if _, err := os.Stat(s.JCPPath); err != nil {
|
||||
setupFlash(w, r, "crypto: jcp_path не найден: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdateCrypto(s); err != nil {
|
||||
setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := NSDSettings{
|
||||
Profile: strings.TrimSpace(r.FormValue("profile")),
|
||||
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
|
||||
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
|
||||
}
|
||||
if s.IGWBaseURL != "" {
|
||||
if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
|
||||
setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := tryHTTPHealth(s.IGWBaseURL + "/healthz"); err != nil {
|
||||
setupFlash(w, r, "nsd: ИШ не отвечает на /healthz: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdateNSD(s); err != nil {
|
||||
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "nsd-adapter: настройки сохранены")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))}
|
||||
if s.CallbackURL != "" {
|
||||
if _, err := url.ParseRequestURI(s.CallbackURL); err != nil {
|
||||
setupFlash(w, r, "lk: невалидный URL: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil {
|
||||
setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := h.rc.UpdateLK(s); err != nil {
|
||||
setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
if s.CallbackURL != "" {
|
||||
h.svc.callbackURL = s.CallbackURL
|
||||
}
|
||||
setupFlash(w, r, "Callback в ЛК сохранён и применён")
|
||||
}
|
||||
|
||||
// testRun запускает тестовую заявку с предустановленными данными,
|
||||
// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет
|
||||
// результат в RuntimeConfig.LastTest.
|
||||
func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
go h.runTestClaim()
|
||||
setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд")
|
||||
}
|
||||
|
||||
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
|
||||
func (h *setupHandlers) runTestClaim() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
whole := uint64(1500)
|
||||
req := CreateClaimRequest{
|
||||
Investor: Investor{
|
||||
ID: "11111111-1111-1111-1111-111111111111",
|
||||
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
|
||||
Document: Document{DocumentType: "21", Series: "4512", Number: "654321"},
|
||||
},
|
||||
TransferringDepositoryINN: "0702345678",
|
||||
ReceivingDepositoryINN: "0710987654",
|
||||
CostInfo: CostInfo{No: &struct{}{}},
|
||||
IIAAgreement: &IIAAgreement{
|
||||
AgreementType: "T03", AgreementNumber: "ИИС78/2024",
|
||||
AgreementDate: "2026-01-15", BrokerINN: "0707083893",
|
||||
},
|
||||
Securities: []ClaimSec{
|
||||
{
|
||||
SecurityCode: "MM0766162534",
|
||||
SecurityDetails: SecurityDetails{ISIN: "RU0007661625"},
|
||||
Quantity: Quantity{Whole: whole},
|
||||
SettlementAccounts: []SettlementAccount{
|
||||
{
|
||||
SettlementRequisitesINN: "7702070139",
|
||||
SettlementLocation: SettlementLocation{
|
||||
DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
|
||||
SignatureFormat: "XMLDSig-GOST",
|
||||
}
|
||||
|
||||
res := TestRunResult{StartedAt: time.Now().UTC()}
|
||||
created, err := h.svc.CreateClaim(ctx, req)
|
||||
if err != nil {
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.Message = "CreateClaim упал: " + err.Error()
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
}
|
||||
res.ClaimID = created.ID
|
||||
|
||||
// Опрашиваем статус каждые 200ms до перехода в финал.
|
||||
deadline := time.Now().Add(25 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
view, err := h.svc.GetClaim(ctx, created.ID)
|
||||
if err != nil {
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.FinalStatus = "lookup_failed"
|
||||
res.Message = err.Error()
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
}
|
||||
switch view.Status {
|
||||
case "confirmed", "awaiting_sub16", "done":
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = true
|
||||
res.FinalStatus = view.Status
|
||||
res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)."
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
case "rejected", "timed_out":
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.FinalStatus = view.Status
|
||||
res.Message = "Заявка не прошла: статус " + view.Status
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
res.FinishedAt = time.Now().UTC()
|
||||
res.OK = false
|
||||
res.FinalStatus = "timeout_waiting"
|
||||
res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)"
|
||||
_ = h.rc.RecordTestRun(res)
|
||||
}
|
||||
|
||||
// tryPingPostgres пытается sql.Open + Ping с прокачкой драйвера; без
|
||||
// драйвера вернёт «unknown driver pgx»/«unknown driver postgres» —
|
||||
// тоже считаем ошибкой и показываем пользователю.
|
||||
func tryPingPostgres(dsn string) error {
|
||||
// Угадываем имя драйвера по префиксу.
|
||||
driver := "postgres"
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
driver = "postgres"
|
||||
}
|
||||
db, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return db.PingContext(ctx)
|
||||
}
|
||||
|
||||
// tryHTTPHealth делает GET и ждёт 2xx.
|
||||
func tryHTTPHealth(u string) error {
|
||||
c := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := c.Get(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query.
|
||||
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// _q извлекает Request из ResponseWriter trick — здесь не нужно
|
||||
// (всегда работаем через chain).
|
||||
func _q(_ http.ResponseWriter) string { return "" }
|
||||
func errMsgFromQuery(_ string) string { return "" }
|
||||
|
||||
// guard — заглушка для совместимости с возможным расширением.
|
||||
var _ = errors.New
|
||||
@@ -0,0 +1,145 @@
|
||||
{{define "content"}}
|
||||
{{if .Flash}}<div style="padding:12px 16px;background:rgba(63,191,108,0.1);border-left:3px solid var(--ok);border-radius:4px;margin-bottom:16px">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Готовность системы: {{.ReadyCount}} из {{.TotalCount}}</h2>
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
{{range .Readiness}}
|
||||
<div style="flex:1;text-align:center;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
|
||||
<span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span>
|
||||
<strong>{{.Name}}</strong><br>
|
||||
<span class="muted" style="font-size:11px">{{if .Configured}}настроено{{else}}не настроено{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PostgreSQL -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span>PostgreSQL</h2>
|
||||
<p class="muted">Принимающая БД (fansy-store) и журнал сделок m2m-core. Сейчас:
|
||||
{{if .Settings.Postgres.DSN}}<code>настроено</code>{{else}}<code>in-memory</code> (M2-демо){{end}}.</p>
|
||||
<details {{if not .Settings.Postgres.DSN}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры подключения</summary>
|
||||
<form method="post" action="/admin/setup/postgres" style="margin-top:12px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>DSN</label>
|
||||
<input type="text" name="dsn" value="{{.Settings.Postgres.DSN}}" placeholder="postgres://bj:secret@127.0.0.1:5432/bj?sslmode=disable" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">При сохранении выполняется Ping. Если драйвер pgx ещё не подключён в коде, тест упадёт — это ожидаемо до M2-шага-3.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Crypto-service / КриптоПро JCP -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if and .Settings.Crypto.JCPPath .Settings.Crypto.LicenseKey}}ok{{else}}err{{end}}"></span>Криптография (КриптоПро JCP)</h2>
|
||||
<p class="muted">{{ index .Readiness 1 | printf "%v" }}</p>
|
||||
<table style="margin-bottom:12px">
|
||||
<tr><td style="width:200px" class="muted">Текущий провайдер</td><td><code>{{.Settings.Crypto.Provider}}</code></td></tr>
|
||||
<tr><td class="muted">UDS-сокет</td><td><code>{{.Settings.Crypto.SocketPath}}</code></td></tr>
|
||||
<tr><td class="muted">Путь к jcp.jar</td><td><code>{{if .Settings.Crypto.JCPPath}}{{.Settings.Crypto.JCPPath}}{{else}}—{{end}}</code></td></tr>
|
||||
<tr><td class="muted">Лицензия введена</td><td>{{if .Settings.Crypto.LicenseKey}}<span style="color:var(--ok)">да</span>{{else}}<span style="color:var(--err)">нет</span>{{end}}</td></tr>
|
||||
</table>
|
||||
<details {{if or (eq .Settings.Crypto.Provider "stub") (not .Settings.Crypto.JCPPath)}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры криптографии</summary>
|
||||
<form method="post" action="/admin/setup/crypto" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Провайдер</label>
|
||||
<select name="provider" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="stub" {{if eq .Settings.Crypto.Provider "stub"}}selected{{end}}>stub (без криптографии, для демо)</option>
|
||||
<option value="cryptopro" {{if eq .Settings.Crypto.Provider "cryptopro"}}selected{{end}}>КриптоПро JCP</option>
|
||||
<option value="validata" {{if eq .Settings.Crypto.Provider "validata"}}selected{{end}}>Валидата JCP</option>
|
||||
<option value="vipnet" {{if eq .Settings.Crypto.Provider "vipnet"}}selected{{end}}>ViPNet</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>UDS-сокет</label>
|
||||
<input type="text" name="socket_path" value="{{.Settings.Crypto.SocketPath}}" placeholder="/run/bj/crypto.sock" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Путь к jcp.jar</label>
|
||||
<input type="text" name="jcp_path" value="{{.Settings.Crypto.JCPPath}}" placeholder="/opt/cryptopro/jcp.jar" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:flex-start">
|
||||
<label>Лицензионный ключ</label>
|
||||
<textarea name="license_key" rows="3" placeholder="XXXX-XXXX-XXXX-XXXX-XXXX или весь лицензионный файл" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;font-family:monospace;font-size:12px">{{.Settings.Crypto.LicenseKey}}</textarea>
|
||||
</div>
|
||||
<p class="muted">При выборе <code>cryptopro</code> jcp.jar должен лежать на ВМ (положите в <code>services/crypto-service/libs/jcp.jar</code>). При сохранении проверим что файл существует.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- nsd-adapter / ИШ НРД -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.NSD.IGWBaseURL}}ok{{else}}err{{end}}"></span>Интеграционный шлюз НРД</h2>
|
||||
<p class="muted">{{if not .Settings.NSD.IGWBaseURL}}Сейчас <code>mock-режим</code> — Decision эмитируется через 3 секунды после Send.{{else}}Профиль <code>{{.Settings.NSD.Profile}}</code>, ИШ <code>{{.Settings.NSD.IGWBaseURL}}</code>.{{end}}</p>
|
||||
<details {{if not .Settings.NSD.IGWBaseURL}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Изменить параметры ИШ</summary>
|
||||
<form method="post" action="/admin/setup/nsd" style="margin-top:12px;display:grid;gap:10px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Профиль</label>
|
||||
<select name="profile" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="" {{if eq .Settings.NSD.Profile ""}}selected{{end}}>— mock (демо) —</option>
|
||||
<option value="guest-gost" {{if eq .Settings.NSD.Profile "guest-gost"}}selected{{end}}>guest-gost</option>
|
||||
<option value="guest-rsa" {{if eq .Settings.NSD.Profile "guest-rsa"}}selected{{end}}>guest-rsa</option>
|
||||
<option value="test3-gost" {{if eq .Settings.NSD.Profile "test3-gost"}}selected{{end}}>test3-gost</option>
|
||||
<option value="test3-rsa" {{if eq .Settings.NSD.Profile "test3-rsa"}}selected{{end}}>test3-rsa</option>
|
||||
<option value="prod-gost" {{if eq .Settings.NSD.Profile "prod-gost"}}selected{{end}}>prod-gost</option>
|
||||
<option value="prod-rsa" {{if eq .Settings.NSD.Profile "prod-rsa"}}selected{{end}}>prod-rsa</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>URL ИШ НРД</label>
|
||||
<input type="text" name="igw_base_url" value="{{.Settings.NSD.IGWBaseURL}}" placeholder="http://localhost:8080 (адрес ИШ НРД)" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Ключевой контейнер</label>
|
||||
<input type="text" name="key_container" value="{{.Settings.NSD.KeyContainer}}" placeholder="GUEST_GOST_CONTAINER" style="padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted">При сохранении выполняется GET <code>{URL}/healthz</code>. Пустой URL = вернуться к mock-режиму.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- LK callback -->
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.LK.CallbackURL}}ok{{else}}err{{end}}"></span>Callback в ЛК</h2>
|
||||
<p class="muted">{{if .Settings.LK.CallbackURL}}Callback URL: <code>{{.Settings.LK.CallbackURL}}</code>{{else}}Сейчас используется встроенный lk-emulator (он сам зарегистрировал свой адрес при старте).{{end}}</p>
|
||||
<details {{if not .Settings.LK.CallbackURL}}open{{end}}>
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:13px">Указать URL реального ЛК</summary>
|
||||
<form method="post" action="/admin/setup/lk" style="margin-top:12px">
|
||||
<div class="form-row" style="display:grid;grid-template-columns:200px 1fr;gap:12px;align-items:center">
|
||||
<label>Callback URL</label>
|
||||
<input type="text" name="callback_url" value="{{.Settings.LK.CallbackURL}}" placeholder="http://lk.example.com" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
</div>
|
||||
<p class="muted" style="margin-top:8px">URL до базового хоста ЛК (без /api). При сохранении выполняется GET <code>{URL}/healthz</code>.</p>
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:8px 16px;border-radius:4px;margin-top:8px">Сохранить и проверить</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Тестовый прогон -->
|
||||
<div class="card">
|
||||
<h2>Тестовый прогон сквозной заявки</h2>
|
||||
<p class="muted">Создаст заявку с предзаполненными данными (инвестор Иванов И.И., 1500 акций Газпрома, ИИС T03), отправит её через всю цепочку и дождётся финального статуса. Если ИШ НРД настроен — пойдёт в реальный ИШ; иначе через mock с задержкой 3 сек.</p>
|
||||
<form method="post" action="/admin/setup/test-run" style="margin-top:12px">
|
||||
<button type="submit" class="btn" style="background:var(--accent);color:white;border:none;padding:10px 20px;border-radius:4px;font-weight:600">Запустить тестовую заявку</button>
|
||||
</form>
|
||||
{{if .Settings.LastTest}}
|
||||
<div style="margin-top:16px;padding:12px;background:var(--bg);border:1px solid var(--border);border-radius:4px">
|
||||
<strong>Последний прогон:</strong>
|
||||
<table style="margin-top:8px">
|
||||
<tr><td style="width:160px" class="muted">Статус</td><td>{{if .Settings.LastTest.OK}}<span style="color:var(--ok)">✓ успешно</span>{{else}}<span style="color:var(--err)">✗ не прошёл</span>{{end}}</td></tr>
|
||||
<tr><td class="muted">Финальный FSM-статус</td><td><code>{{.Settings.LastTest.FinalStatus}}</code></td></tr>
|
||||
<tr><td class="muted">ClaimID</td><td><code>{{.Settings.LastTest.ClaimID}}</code> {{if .Settings.LastTest.ClaimID}}<a href="/admin/claims/{{.Settings.LastTest.ClaimID}}">→ открыть карточку</a>{{end}}</td></tr>
|
||||
<tr><td class="muted">Когда</td><td>{{.Settings.LastTest.StartedAt.Format "02.01.2006 15:04:05"}} — длительность {{.Settings.LastTest.FinishedAt.Sub .Settings.LastTest.StartedAt}}</td></tr>
|
||||
<tr><td class="muted">Сообщение</td><td>{{.Settings.LastTest.Message}}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -45,6 +45,7 @@ button:hover, .btn:hover { opacity: .9; }
|
||||
<h1>lk-gateway</h1>
|
||||
<nav>
|
||||
<a href="/admin/" class="{{if eq .Active "home"}}active{{end}}">Дашборд</a>
|
||||
<a href="/admin/setup" class="{{if eq .Active "setup"}}active{{end}}">Настройка</a>
|
||||
<a href="/admin/claims" class="{{if eq .Active "claims"}}active{{end}}">Заявки</a>
|
||||
<a href="/admin/status" class="{{if eq .Active "status"}}active{{end}}">Статус системы</a>
|
||||
</nav>
|
||||
|
||||
Reference in New Issue
Block a user