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 help, helpDatabase, helpLK, helpCryptoPro, helpSystems, helpRobot, helpArchitecture *template.Template wizard, news *template.Template } // templateFuncs — функции, доступные внутри шаблонов. Главная задача — // русификация статусов и других технических обозначений (см. требование // «всё UI на русском, кроме программных терминов»). var templateFuncs = template.FuncMap{ "ru": russianText, "ruState": russianState, "ruOutcome": russianOutcome, "now": time.Now, } // russianState переводит технический FSM-state в человекочитаемый // русский, сохраняя CSS-класс для бейджа. func russianState(s string) string { switch s { case "draft": return "Черновик" case "validated": return "Валидирована" case "submitted_to_nsd": return "Отправлена в НРД" case "awaiting_decision": return "Ожидает решение" case "confirmed": return "Подтверждена" case "awaiting_sub16": return "Ожидает SUB16" case "done": return "Завершена" case "rejected": return "Отклонена" case "timed_out": return "Таймаут SLA" case "manual_approval": return "На ручном разборе" } return s } // russianOutcome — для NSDDecisionSecurity.Outcome. func russianOutcome(o string) string { switch o { case "confirmed": return "Подтверждено" case "rejected": return "Отказ" } return o } // russianText — fallback функция для произвольных строк (на случай // будущих расширений). Сейчас возвращает строку без изменений. func russianText(s string) string { return s } func newAdmin() (*admin, error) { parse := func(content string) (*template.Template, error) { return template.New("layout.html").Funcs(templateFuncs).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) } help, err := parse("admin_help.html") if err != nil { return nil, fmt.Errorf("parse admin_help: %w", err) } helpDB, err := parse("admin_help_database.html") if err != nil { return nil, fmt.Errorf("parse admin_help_database: %w", err) } helpLK, err := parse("admin_help_lk.html") if err != nil { return nil, fmt.Errorf("parse admin_help_lk: %w", err) } helpCP, err := parse("admin_help_cryptopro.html") if err != nil { return nil, fmt.Errorf("parse admin_help_cryptopro: %w", err) } helpSys, err := parse("admin_help_systems.html") if err != nil { return nil, fmt.Errorf("parse admin_help_systems: %w", err) } wizard, err := parse("admin_wizard.html") if err != nil { return nil, fmt.Errorf("parse admin_wizard: %w", err) } news, err := parse("admin_news.html") if err != nil { return nil, fmt.Errorf("parse admin_news: %w", err) } helpRobot, err := parse("admin_help_robot.html") if err != nil { return nil, fmt.Errorf("parse admin_help_robot: %w", err) } helpArch, err := parse("admin_help_architecture.html") if err != nil { return nil, fmt.Errorf("parse admin_help_architecture: %w", err) } return &admin{ home: home, claims: claims, claim: claim, status: status, setup: setup, help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys, helpRobot: helpRobot, helpArchitecture: helpArch, wizard: wizard, news: news, }, nil } // page — общий "конверт" данных для всех шаблонов. type page struct { Title string Active string Now string IsMockMode bool // true если ИШ не настроен — bj-server в режиме эмуляции MockReason string // короткое описание почему mock } // globalRC — ссылка на runtime-конфиг для template-funcs/page helpers. // Заполняется один раз в RegisterAdmin. Альтернатива — таскать rc через // все renderXxx-функции, что шумно при широком фан-ауте. var globalRC *RuntimeConfig // homeData — данные дашборда. type homeData struct { page Status SystemStatus Counts struct { Total int Confirmed int InProgress int Failed int } Recent []ClaimView News []NewsItem // top-3 активных или свежих новостей } // 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, rc *RuntimeConfig, getOpts func() CheckOptions) (*admin, error) { a, err := newAdmin() if err != nil { return nil, err } globalRC = rc 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, rc, 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()) case p == "help": render(w, a.help, nowPage("Инструкции", "help")) case p == "help/database": render(w, a.helpDatabase, nowPage("База данных", "help")) case p == "help/lk-api": render(w, a.helpLK, nowPage("API ЛК", "help")) case p == "help/cryptopro": render(w, a.helpCryptoPro, nowPage("КриптоПро", "help")) case p == "help/systems": render(w, a.helpSystems, nowPage("Внешние системы", "help")) case p == "help/robot": render(w, a.helpRobot, nowPage("Тестирование с роботом", "help")) case p == "help/architecture": render(w, a.helpArchitecture, nowPage("Архитектура обмена", "help")) 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, rc *RuntimeConfig, 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, News: topNews(rc.Snapshot().News.Items, 3), } 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 { p := page{Title: title, Active: active, Now: time.Now().Format("02.01.2006 15:04:05")} if globalRC != nil { s := globalRC.Snapshot() switch { case s.NSD.IGWBaseURL == "": p.IsMockMode = true p.MockReason = "ИШ НРД не настроен — заявки идут через внутренний mock (Decision эмитируется через 3 сек)" case s.Crypto.Provider == "" || s.Crypto.Provider == "stub": p.IsMockMode = true p.MockReason = "Провайдер СКЗИ = stub — подпись не делается, реальный обмен с НРД невозможен" } } return p } // topNews отбирает максимум N новостей: сначала те, что активны прямо сейчас // (по ValidFrom..ValidTo), потом просто свежие. Скрытые (Dismissed) — мимо. func topNews(items []NewsItem, n int) []NewsItem { now := time.Now() var active, rest []NewsItem for _, it := range items { if it.Dismissed { continue } isActive := !it.ValidFrom.IsZero() && !it.ValidTo.IsZero() && now.After(it.ValidFrom) && now.Before(it.ValidTo) // «Будущие» окна с ValidFrom в будущем тоже считаем актуальными // (предупредить заранее). isUpcoming := !it.ValidFrom.IsZero() && now.Before(it.ValidFrom) && it.ValidFrom.Sub(now) < 7*24*time.Hour if isActive || isUpcoming { active = append(active, it) } else { rest = append(rest, it) } } out := active if len(out) < n { need := n - len(out) if need > len(rest) { need = len(rest) } out = append(out, rest[:need]...) } if len(out) > n { out = out[:n] } return out }