1ffe62133c
Когда ИШ НРД не настроен (NSD.IGWBaseURL пустой) или провайдер СКЗИ = stub — рисуем жёлтую плашку сверху каждой страницы админки с явной надписью «РЕЖИМ ЭМУЛЯЦИИ — реального обмена с НРД нет» и ссылкой «Настроить →» на /admin/wizard. Это нужно чтобы пользователь видя быстро-подтверждённую заявку не думал что это настоящий обмен. Реализация: добавлено поле IsMockMode/MockReason в page struct, nowPage() читает globalRC.Snapshot() и заполняет их. globalRC — пакетная переменная (заполняется один раз в RegisterAdmin), чтобы не таскать *RuntimeConfig через все renderXxx-вызовы. Сам баннер — в layout.html перед <main>, поэтому виден везде включая карточку заявки.
344 lines
11 KiB
Go
344 lines
11 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
|
|
help, helpDatabase, helpLK, helpCryptoPro, helpSystems *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)
|
|
}
|
|
return &admin{
|
|
home: home, claims: claims, claim: claim, status: status, setup: setup,
|
|
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCryptoPro: helpCP, helpSystems: helpSys,
|
|
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"))
|
|
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
|
|
}
|