НПР / УДС — отдельная служба bj-npr

Модуль вынесен в modules/npr + cmd/bj-npr (127.0.0.1:8083), обновляется
независимо от ядра. Служба читает подключение из setup.json и принимает
запросы только со служебным ключом модулей (/var/lib/bj/.bj/module.key,
создаёт bj-server). Полные страницы отдаются фрагментом (X-BJ-Layout: page).

bj-server хранит настройки и проверку подключения, проксирует /admin/npr
после входа, прав и лицензии и встраивает страницы модуля в общий макет
(admin.modulePage); если служба не отвечает — понятная страница.
Unit bj-npr.service встроен в установщик, установщик включает службы
установленных модулей; build-release.sh собирает bj-npr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 01:00:31 +03:00
parent d8dee07213
commit 5527a464ca
15 changed files with 872 additions and 467 deletions
+13 -21
View File
@@ -22,13 +22,13 @@ var templatesFS embed.FS
// конкретный content-шаблон). Так html/template не путается с несколькими
// {{define "content"}} в разных файлах.
type admin struct {
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, npr, nprClient, nprRows *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim *template.Template
journal *template.Template
home, claims, claim, status, setup *template.Template
help, helpDatabase, helpLK, helpCrypto, helpSystems, helpRobot, helpArchitecture, helpQuik, helpFancyEvents, modulePage *template.Template
incoming, incomingDetail, fancyQueue, quikInstr *template.Template
wizard, news, keyWizard *template.Template
handbook *template.Template
m2mtest, m2mpreview, depoSim *template.Template
journal *template.Template
}
// templateFuncs — функции, доступные внутри шаблонов. Главная задача —
@@ -51,7 +51,6 @@ var templateFuncs = template.FuncMap{
// m2mCode — человекочитаемое название кода M2M: «Текст (M2Mxx)».
"m2mCode": m2mCodeLabel,
"quikCodeMap": quikClientCodeMapText,
"nprF": nprF,
"curUser": authTemplateFuncs["curUser"],
"can": authTemplateFuncs["can"],
"authOpen": authOpen,
@@ -277,20 +276,13 @@ func newAdmin() (*admin, error) {
if err != nil {
return nil, fmt.Errorf("parse admin_help_fancy_events: %w", err)
}
parseNPR := func(content string) (*template.Template, error) {
return template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html", "web/templates/npr_css.html", "web/templates/"+content)
// Страница модуля (служба модуля отдаёт содержимое, макет и меню — bj): npr.go.
modulePage, err := template.New("layout.html").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/layout.html")
if err == nil {
_, err = modulePage.New("content").Parse(`{{.Content}}`)
}
nprT, err := parseNPR("admin_npr.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr: %w", err)
}
nprC, err := parseNPR("admin_npr_client.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr_client: %w", err)
}
nprR, err := template.New("npr_rows").Funcs(templateFuncs).ParseFS(templatesFS, "web/templates/admin_npr_rows.html")
if err != nil {
return nil, fmt.Errorf("parse admin_npr_rows: %w", err)
return nil, fmt.Errorf("parse module page: %w", err)
}
keyWizard, err := parse("admin_keywizard.html")
if err != nil {
@@ -335,7 +327,7 @@ func newAdmin() (*admin, error) {
return &admin{
home: home, claims: claims, claim: claim, status: status, setup: setup,
help: help, helpDatabase: helpDB, helpLK: helpLK, helpCrypto: helpCrypto, helpSystems: helpSys,
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, npr: nprT, nprClient: nprC, nprRows: nprR,
helpRobot: helpRobot, helpArchitecture: helpArch, helpQuik: helpQuik, helpFancyEvents: helpFancyEvents, modulePage: modulePage,
wizard: wizard, news: news, keyWizard: keyWizard,
incoming: incoming, incomingDetail: incomingDetail, fancyQueue: fancyQueue, quikInstr: quikInstr,
handbook: handbook,
+137 -440
View File
@@ -1,32 +1,28 @@
package lkgateway
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли поверх базы
// экспорта QUIK (quik_export, схема npr). Перенесён «как есть» с 10.10.10.23
// (FastAPI ui/app.py) 14.09.2026; читает базу логином только для чтения.
// Включается настройкой NPR.Enabled и лицензионным признаком "npr".
// Модуль «НПР / УДС» — риск-монитор клиентов маржинальной торговли по базе экспорта QUIK.
// Сам модуль работает отдельной службой bj-npr (modules/npr, обновляется независимо). Здесь —
// его настройки и проверка подключения, а раздел /admin/npr bj-server проксирует службе: проверяет
// включение и лицензию (права — authMiddleware), передаёт служебный ключ и встраивает полные
// страницы модуля в свой макет. Если служба не отвечает, показывает понятную страницу.
import (
"context"
"embed"
"encoding/csv"
"encoding/json"
"crypto/rand"
"encoding/hex"
"fmt"
"io/fs"
"log"
"math"
"html/template"
"io"
"net/http"
"strconv"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed web/static/npr/*
var nprStaticFS embed.FS
// NPRSettings — модуль НПР/УДС.
type NPRSettings struct {
Enabled bool `json:"enabled"`
@@ -50,6 +46,125 @@ func nprAvailable(rc *RuntimeConfig) bool {
return s.Enabled && strings.TrimSpace(s.DSN) != "" && moduleAllowed(rc, "npr")
}
// ---- служба модуля ----
const moduleKeyFile = "/var/lib/bj/.bj/module.key"
var (
moduleKeyMu sync.Mutex
nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
)
// moduleKey — служебный ключ для служб модулей (создаётся один раз, читается службами от того же пользователя).
func moduleKey() (string, error) {
moduleKeyMu.Lock()
defer moduleKeyMu.Unlock()
if b, err := os.ReadFile(moduleKeyFile); err == nil && len(strings.TrimSpace(string(b))) >= 32 {
return strings.TrimSpace(string(b)), nil
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
key := hex.EncodeToString(buf)
if err := os.WriteFile(moduleKeyFile, []byte(key+"\n"), 0o600); err != nil {
return "", err
}
return key, nil
}
func nprURL() string {
if u := strings.TrimSpace(os.Getenv("BJ_NPR_URL")); u != "" {
return strings.TrimRight(u, "/")
}
return "http://127.0.0.1:8083"
}
// modulePageData — страница модуля в макете bj.
type modulePageData struct {
page
Content template.HTML
}
func moduleMessage(title, text, hint string) template.HTML {
return template.HTML(`<div class="card"><h2>` + template.HTMLEscapeString(title) + `</h2><p>` + template.HTMLEscapeString(text) + `</p>` +
`<p class="muted">` + template.HTMLEscapeString(hint) + `</p></div>`)
}
// registerNPR — прокси раздела /admin/npr к службе bj-npr.
func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
unavailable := func(w http.ResponseWriter, r *http.Request, status int, title, text, hint string) {
if isAjax(r) || !strings.HasPrefix(r.Header.Get("Accept"), "text/html") && r.URL.Path != "/admin/npr" && !strings.HasPrefix(r.URL.Path, "/admin/npr/c/") {
http.Error(w, text, status)
return
}
w.WriteHeader(status)
render(w, a.modulePage, modulePageData{page: nowPage("НПР / УДС", "npr"), Content: moduleMessage(title, text, hint)})
}
proxy := func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/admin/npr/static/") && !nprAvailable(rc) {
unavailable(w, r, http.StatusForbidden, "Модуль «НПР / УДС» недоступен",
"Модуль не включён в настройках или не входит в лицензию.", "Включение и подключение к базе: Настройка → НПР / УДС. Состав лицензии: Настройка → Лицензия.")
return
}
key, err := moduleKey()
if err != nil {
http.Error(w, "служебный ключ модулей: "+err.Error(), http.StatusInternalServerError)
return
}
req, err := http.NewRequestWithContext(r.Context(), r.Method, nprURL()+r.URL.RequestURI(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, h := range []string{"Accept", "Content-Type", "HX-Request", "HX-Target", "HX-Trigger", "HX-Current-URL", "If-Modified-Since", "If-None-Match", "Range"} {
if v := r.Header.Get(h); v != "" {
req.Header.Set(h, v)
}
}
req.Header.Set("X-BJ-Module-Key", key)
if u := currentUser(w); u != nil {
req.Header.Set("X-BJ-User", url.QueryEscape(u.Login))
}
resp, err := nprHTTP.Do(req)
if err != nil {
unavailable(w, r, http.StatusServiceUnavailable, "Служба модуля не отвечает",
"Служба bj-npr не запущена или ещё не установлена на этом сервере.",
"Установка и обновление модуля: Настройка → Обновления. Состояние службы: systemctl status bj-npr.")
return
}
defer resp.Body.Close()
if resp.Header.Get("X-BJ-Layout") == "page" {
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
title, _ := url.PathUnescape(resp.Header.Get("X-BJ-Title"))
render(w, a.modulePage, modulePageData{page: nowPage(pickStr(title, "НПР / УДС"), "npr"), Content: template.HTML(body)})
return
}
for _, h := range []string{"Content-Type", "Content-Disposition", "Content-Length", "Cache-Control", "Last-Modified", "ETag", "Accept-Ranges", "Content-Range", "Location"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
mux.HandleFunc("/admin/npr", proxy)
mux.HandleFunc("/admin/npr/", proxy)
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
// ---- настройки: проверка подключения ----
var (
nprPoolMu sync.Mutex
nprPool *pgxpool.Pool
@@ -74,7 +189,7 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
if err != nil {
return nil, err
}
cfg.MaxConns = 5
cfg.MaxConns = 2
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
p, err := pgxpool.NewWithConfig(ctx, cfg)
@@ -85,429 +200,6 @@ func nprDB(rc *RuntimeConfig) (*pgxpool.Pool, error) {
return p, nil
}
var nprMSK = time.FixedZone("MSK", 3*3600)
var nprSort = map[string]string{
"uds_asc": "uds ASC NULLS LAST, client_code ASC",
"uds_desc": "uds DESC NULLS LAST, client_code ASC",
"client_asc": "client_code ASC",
"client_desc": "client_code DESC",
"ts_desc": "ts DESC",
}
var nprTabs = map[string]string{
// Маржинальный риск: клиенты с реально открытыми позициями.
"margin": "COALESCE(init_margin,0) > 0 OR COALESCE(min_margin,0) > 0",
// Дебиторская задолженность: позиций нет, но портфель в минусе.
"debt": "COALESCE(init_margin,0) = 0 AND COALESCE(min_margin,0) = 0 AND COALESCE(portfolio_value,0) < 0",
}
type nprRow struct {
ClientCode, MarginType, UDSFlag string
UDS, RCV1, RCV2, MarginReq *float64
PortfolioValue *float64
InitMargin, MinMargin, CorrMargin *float64
Status, RiskLevel *int
TS time.Time
Zone string
}
func (r nprRow) Class() string {
switch {
case r.UDSFlag == "CALL" || (r.UDS != nil && *r.UDS < 1):
return "row-call"
case r.UDS != nil && *r.UDS < 1.5:
return "row-warn"
case r.UDSFlag == "INF":
return "row-inf"
}
return ""
}
func (r nprRow) Badge() (cls, text string) {
switch {
case r.UDSFlag == "CALL":
return "badge-call", "МАРЖИН-КОЛЛ"
case r.UDSFlag == "INF":
return "badge-inf", "∞ избыточно"
case r.UDS != nil && *r.UDS < 1:
return "badge-call", "маржин-колл"
case r.UDS != nil && *r.UDS < 1.5:
return "badge-warn", "внимание"
case r.UDS != nil:
return "badge-ok", "норма"
}
return "", "—"
}
func (r nprRow) BadgeClass() string { c, _ := r.Badge(); return c }
func (r nprRow) BadgeText() string { _, t := r.Badge(); return t }
func nprF(v *float64) string {
if v == nil || math.IsNaN(*v) {
return "—"
}
return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 2, 64), ".", ",")
}
func nprI(v *int) string {
if v == nil {
return ""
}
return strconv.Itoa(*v)
}
func nprParseF(s string) *float64 {
s = strings.TrimSpace(strings.ReplaceAll(s, ",", "."))
if s == "" {
return nil
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return nil
}
return &f
}
func nprWhere(q string, mt []string, umn, umx *float64, onlyCalls bool, tab string) (string, []any) {
var cl []string
var params []any
cl = append(cl, "("+nprTabs[tab]+")")
if q != "" {
params = append(params, "%"+q+"%")
cl = append(cl, fmt.Sprintf("client_code ILIKE $%d", len(params)))
}
if len(mt) > 0 {
params = append(params, mt)
cl = append(cl, fmt.Sprintf("margin_type = ANY($%d)", len(params)))
}
if umn != nil {
params = append(params, *umn)
cl = append(cl, fmt.Sprintf("uds >= $%d", len(params)))
}
if umx != nil {
params = append(params, *umx)
cl = append(cl, fmt.Sprintf("uds <= $%d", len(params)))
}
if onlyCalls {
cl = append(cl, "uds IS NOT NULL AND uds < 1")
}
return strings.Join(cl, " AND "), params
}
func scanNPR(rows pgx.Rows) ([]nprRow, int, error) {
var out []nprRow
total := 0
for rows.Next() {
var r nprRow
var mt, fl *string
var zone *string
if err := rows.Scan(&r.ClientCode, &mt, &r.UDS, &fl, &r.RCV1, &r.RCV2, &r.MarginReq, &r.PortfolioValue, &r.InitMargin, &r.MinMargin, &r.CorrMargin, &r.Status, &r.RiskLevel, &r.TS, &zone, &total); err != nil {
return nil, 0, err
}
if mt != nil {
r.MarginType = *mt
}
if fl != nil {
r.UDSFlag = *fl
}
if zone != nil {
r.Zone = *zone
}
out = append(out, r)
}
return out, total, rows.Err()
}
const nprCols = `client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, corrected_margin::float8, status, risk_level, ts, zone`
func nprParsePeriod(hours int, from, to string) (time.Time, time.Time, bool) {
parse := func(s string) (time.Time, bool) {
for _, f := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04", "2006-01-02"} {
if t, err := time.ParseInLocation(f, s, nprMSK); err == nil {
return t, true
}
}
return time.Time{}, false
}
now := time.Now()
tf, okf := parse(from)
tt, okt := parse(to)
if okf || okt {
if !okf {
tf = now.Add(-2 * 365 * 24 * time.Hour)
}
if !okt {
tt = now
}
return tf, tt, true
}
if hours <= 0 {
hours = 24
}
return now.Add(-time.Duration(hours) * time.Hour), now, false
}
// registerNPR — маршруты модуля.
func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
sub, _ := fs.Sub(nprStaticFS, "web/static/npr")
mux.Handle("/admin/npr/static/", http.StripPrefix("/admin/npr/static/", http.FileServer(http.FS(sub))))
guard := func(w http.ResponseWriter, r *http.Request) (*pgxpool.Pool, bool) {
if !nprAvailable(rc) {
http.Error(w, "модуль «НПР / УДС» не включён или не входит в лицензию", http.StatusForbidden)
return nil, false
}
p, err := nprDB(rc)
if err != nil {
http.Error(w, "модуль НПР: "+err.Error(), http.StatusServiceUnavailable)
return nil, false
}
return p, true
}
qp := func(r *http.Request) (q string, mt []string, umn, umx *float64, onlyCalls bool, sort, tab string, page int) {
v := r.URL.Query()
q = strings.TrimSpace(v.Get("q"))
for _, m := range v["mt"] {
if m = strings.TrimSpace(m); m != "" {
mt = append(mt, m)
}
}
umn, umx = nprParseF(v.Get("umn")), nprParseF(v.Get("umx"))
onlyCalls = v.Get("only_calls") == "true" || v.Get("only_calls") == "on"
sort = v.Get("sort")
if _, ok := nprSort[sort]; !ok {
sort = "uds_asc"
}
tab = v.Get("tab")
if _, ok := nprTabs[tab]; !ok {
tab = "margin"
}
page, _ = strconv.Atoi(v.Get("page"))
if page < 1 {
page = 1
}
return
}
mux.HandleFunc("/admin/npr", func(w http.ResponseWriter, r *http.Request) {
if _, ok := guard(w, r); !ok {
return
}
q, mt, _, _, onlyCalls, sort, tab, pg := qp(r)
data := struct {
page
Q, Sort, Tab string
MT []string
UMN, UMX string
OnlyCalls bool
Page int
}{nowPage("НПР / УДС", "npr"), q, sort, tab, mt, r.URL.Query().Get("umn"), r.URL.Query().Get("umx"), onlyCalls, pg}
render(w, a.npr, data)
})
mux.HandleFunc("/admin/npr/rows", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
q, mt, umn, umx, onlyCalls, sort, tab, page := qp(r)
limit := 30
where, params := nprWhere(q, mt, umn, umx, onlyCalls, tab)
params = append(params, limit, (page-1)*limit)
sql := fmt.Sprintf(`WITH per_client AS (
SELECT DISTINCT ON (client_code) %s FROM npr.v_current
ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
SELECT %s, count(*) OVER () AS total FROM per_client WHERE %s ORDER BY %s LIMIT $%d OFFSET $%d`,
nprCols, nprCols, where, nprSort[sort], len(params)-1, len(params))
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
rows, err := p.Query(ctx, sql, params...)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
list, total, err := scanNPR(rows)
rows.Close()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
pages := (total + limit - 1) / limit
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = a.nprRows.ExecuteTemplate(w, "npr_rows", map[string]any{"Rows": list, "Total": total, "Page": page, "Pages": pages, "HasNext": total > page*limit})
})
mux.HandleFunc("/admin/npr/api/summary", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
tab := r.URL.Query().Get("tab")
if _, ok := nprTabs[tab]; !ok {
tab = "margin"
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var total, below1, below0, calls, inf, warn, okc, tabM, tabD int64
err := p.QueryRow(ctx, `WITH per_client AS (SELECT DISTINCT ON (client_code) client_code, uds, uds_flag, zone, init_margin, min_margin, portfolio_value FROM npr.v_current WHERE `+nprTabs[tab]+` ORDER BY client_code, (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC)
SELECT count(*), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 1), count(*) FILTER (WHERE uds IS NOT NULL AND uds < 0), count(*) FILTER (WHERE zone='CALL'), count(*) FILTER (WHERE zone='INF'), count(*) FILTER (WHERE zone='WARN'), count(*) FILTER (WHERE zone='OK') FROM per_client`).
Scan(&total, &below1, &below0, &calls, &inf, &warn, &okc)
if err == nil {
err = p.QueryRow(ctx, `SELECT count(DISTINCT client_code) FILTER (WHERE `+nprTabs["margin"]+`), count(DISTINCT client_code) FILTER (WHERE `+nprTabs["debt"]+`) FROM npr.v_current`).Scan(&tabM, &tabD)
}
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"total": total, "below_one": below1, "below_zero": below0, "calls": calls, "inf": inf, "warn": warn, "ok": okc, "tab_margin": tabM, "tab_debt": tabD, "tab": tab})
})
mux.HandleFunc("/admin/npr/api/freshness", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var lastTick *time.Time
var ticks int64
_ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks)
var lastTS *time.Time
var total, hour, day int64
_ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day)
out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)}
if lastTick != nil {
out["last_tick"] = lastTick.Format(time.RFC3339)
}
if lastTS != nil {
out["last_event_ts"] = lastTS.Format(time.RFC3339)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
})
mux.HandleFunc("/admin/npr/c/", func(w http.ResponseWriter, r *http.Request) {
p, ok := guard(w, r)
if !ok {
return
}
rest := strings.TrimPrefix(r.URL.Path, "/admin/npr/c/")
code := rest
sub := ""
if i := strings.Index(rest, "/"); i >= 0 {
code, sub = rest[:i], rest[i+1:]
}
if code == "" {
http.NotFound(w, r)
return
}
hours, _ := strconv.Atoi(r.URL.Query().Get("hours"))
tf, tt, custom := nprParsePeriod(hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"))
if hours <= 0 {
hours = 24
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
switch sub {
case "series":
rows, err := p.Query(ctx, "SELECT ts, rcv1::float8, rcv2::float8, uds::float8 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts", code, tf, tt)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
labels, r1, r2, u := []string{}, []*float64{}, []*float64{}, []*float64{}
for rows.Next() {
var ts time.Time
var a, b, c *float64
if rows.Scan(&ts, &a, &b, &c) == nil {
labels = append(labels, ts.Format(time.RFC3339))
r1, r2, u = append(r1, a), append(r2, b), append(u, c)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"labels": labels, "rcv1": r1, "rcv2": r2, "uds": u})
return
case "export.csv":
rows, err := p.Query(ctx, `SELECT h.ts, h.firm_id, h.client_code, COALESCE(h.margin_type,''), h.rcv1::float8, h.rcv2::float8,
CASE WHEN h.funds_level >= 9.99 THEN NULL ELSE h.funds_level::float8 END, CASE WHEN h.funds_level >= 9.99 THEN 'INF' WHEN h.funds_level <= -9.99 THEN 'CALL' ELSE '' END,
h.portfolio_value::float8, h.init_margin::float8, h.min_margin::float8, h.corrected_margin::float8, GREATEST(-h.rcv1, 0)::float8, h.status, h.risk_level
FROM npr.history h WHERE h.client_code=$1 AND h.ts BETWEEN $2 AND $3 ORDER BY h.ts`, code, tf, tt)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
defer rows.Close()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="npr_%s_%s-%s.csv"`, code, tf.Format("20060102"), tt.Format("20060102")))
w.Write([]byte{0xEF, 0xBB, 0xBF})
cw := csv.NewWriter(w)
cw.Comma = ';'
cw.Write([]string{"Время (UTC)", "Время (МСК)", "Фирма", "Код клиента", "Тип клиента", "НПР1", "НПР2", "УДС", "Флаг УДС", "Стоимость портфеля", "Начальная маржа", "Минимальная маржа", "Скорректированная маржа", "Маржинальное требование", "Статус", "Уровень риска"})
f := func(v *float64) string {
if v == nil {
return ""
}
return strings.ReplaceAll(strconv.FormatFloat(*v, 'f', 4, 64), ".", ",")
}
for rows.Next() {
var ts time.Time
var firm, cc, mt, flag string
var a, b, c, pv, im, mm, cm, mr *float64
var st, rl *int
if rows.Scan(&ts, &firm, &cc, &mt, &a, &b, &c, &flag, &pv, &im, &mm, &cm, &mr, &st, &rl) != nil {
continue
}
cw.Write([]string{ts.UTC().Format("2006-01-02 15:04:05"), ts.In(nprMSK).Format("2006-01-02 15:04:05"), firm, cc, mt, f(a), f(b), f(c), flag, f(pv), f(im), f(mm), f(cm), f(mr), nprI(st), nprI(rl)})
}
cw.Flush()
return
case "":
default:
http.NotFound(w, r)
return
}
rows, err := p.Query(ctx, `SELECT `+nprCols+`, 0 FROM npr.v_current WHERE client_code=$1 ORDER BY (COALESCE(abs(rcv1),0) + COALESCE(abs(rcv2),0)) DESC LIMIT 1`, code)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
cur, _, err := scanNPR(rows)
rows.Close()
if err != nil || len(cur) == 0 {
http.Error(w, "Клиент "+code+" не найден в текущем состоянии QUIK", http.StatusNotFound)
return
}
hrows, err := p.Query(ctx, `SELECT client_code, margin_type, uds::float8, uds_flag, rcv1::float8, rcv2::float8, margin_requirement::float8, portfolio_value::float8, init_margin::float8, min_margin::float8, NULL::float8, NULL::int, NULL::int, ts, NULL::text, 0 FROM npr.v_npr WHERE client_code=$1 AND ts BETWEEN $2 AND $3 ORDER BY ts DESC LIMIT 200`, code, tf, tt)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
hist, _, _ := scanNPR(hrows)
hrows.Close()
data := struct {
page
C nprRow
History []nprRow
Hours int
DtFrom, DtTo string
TF, TT time.Time
Custom bool
Periods []struct {
H int
T string
}
}{nowPage("Клиент "+code+" — НПР/УДС", "npr"), cur[0], hist, hours, r.URL.Query().Get("dt_from"), r.URL.Query().Get("dt_to"), tf, tt, custom,
[]struct {
H int
T string
}{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
render(w, a.nprClient, data)
})
log.Printf("npr: модуль зарегистрирован (включён: %v)", nprAvailable(rc))
}
// nprTest — проверка подключения для настроек.
func nprTest(rc *RuntimeConfig) (string, error) {
p, err := nprDB(rc)
@@ -527,7 +219,12 @@ func nprTest(rc *RuntimeConfig) (string, error) {
if tick != nil {
age = fmt.Sprintf("пульс экспортёра %s назад", time.Since(*tick).Round(time.Second))
}
return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s", clients, hist, age), nil
service := "служба bj-npr не отвечает"
if resp, err := (&http.Client{Timeout: 3 * time.Second}).Get(nprURL() + "/healthz"); err == nil {
resp.Body.Close()
service = "служба bj-npr работает"
}
return fmt.Sprintf("клиентов в текущем состоянии %d, записей истории %d, %s; %s", clients, hist, age, service), nil
}
func (h *setupHandlers) saveNPR(w http.ResponseWriter, r *http.Request) {
+32
View File
@@ -0,0 +1,32 @@
package lkgateway
import (
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Страница службы модуля встраивается в макет bj: меню и заголовок bj, содержимое — от модуля.
func TestModulePageWrap(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
frag := `<div id="npr-frag"><script src="/admin/npr/static/htmx.min.js"></script>фрагмент</div>`
render(rec, a.modulePage, modulePageData{page: nowPage("Клиент V3BR061 · НПР / УДС", "npr"), Content: template.HTML(frag)})
if rec.Code != http.StatusOK {
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{frag, "Клиент V3BR061 · НПР / УДС", "</html>"} {
if !strings.Contains(body, want) {
t.Errorf("в странице нет %q", want)
}
}
if strings.Count(body, "<html") != 1 {
t.Errorf("ожидался один документ, <html встречается %d раз", strings.Count(body, "<html"))
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
{{define "content"}}
{{template "npr_css"}}
<script src="/admin/npr/static/htmx.min.js"></script>
<div class="doc-header" style="margin-bottom:12px"><h1>📊 Риск-монитор: НПР и УДС клиентов</h1><p class="lede">НПР1 / НПР2 / УДС клиентов маржинальной торговли по данным экспорта QUIK. Модуль перенесён как есть, доработки — по мере необходимости.</p></div>
<nav class="ntabs">
<a href="?tab=margin" class="{{if eq .Tab "margin"}}active{{end}}">Маржинальный риск <span class="count" id="tab_margin_count">—</span></a>
<a href="?tab=debt" class="{{if eq .Tab "debt"}}active{{end}}">Дебиторская задолженность <span class="count" id="tab_debt_count">—</span></a>
</nav>
{{if eq .Tab "margin"}}<p class="tab-desc">Клиенты с открытыми маржинальными позициями (есть начальная или минимальная маржа). У кого УДС близок к 1 — под маржин-колл, у кого &lt;0 — под принудительное закрытие.</p>
{{else}}<p class="tab-desc">Клиенты без открытых позиций, но с отрицательной стоимостью портфеля (должны брокеру). УДС у таких счетов в QUIK уходит в −9,99 — это задолженность, а не маржин-колл.</p>{{end}}
<div class="nhelp"><strong>УДС</strong> — уровень достаточности средств (чем меньше, тем опаснее). <strong>НПР1</strong> — запас относительно начальной маржи (отрицательный — запрещено наращивать позиции). <strong>НПР2</strong> — запас относительно минимальной маржи (отрицательный — начинается принудительное закрытие).
<div class="legend"><span class="legend-item"><span class="legend-box" style="background:#fee2e2"></span> маржин-колл (УДС&lt;1)</span><span class="legend-item"><span class="legend-box" style="background:#fef3c7"></span> внимание (УДС&lt;1,5)</span><span class="legend-item"><span class="legend-box" style="background:#d1fae5"></span> «∞» избыточно (УДС≥9,99)</span></div></div>
<section class="nsummary" hx-get="/admin/npr/api/summary?tab={{.Tab}}" hx-trigger="load, every 60s" hx-swap="none" hx-on::after-request="try{const d=JSON.parse(event.detail.xhr.responseText);for(const [k,v] of Object.entries({sum_total:d.total,sum_calls:d.calls,sum_warn:d.warn,sum_ok:d.ok,sum_inf:d.inf,tab_margin_count:d.tab_margin,tab_debt_count:d.tab_debt})){const e=document.getElementById(k);if(e)e.textContent=v;}}catch(e){}">
<article><header>всего клиентов</header><strong id="sum_total">—</strong></article>
<article class="tile-call"><header>маржин-колл (УДС&lt;1)</header><strong id="sum_calls">—</strong></article>
<article class="tile-warn"><header>внимание (УДС&lt;1,5)</header><strong id="sum_warn">—</strong></article>
<article class="tile-ok"><header>норма</header><strong id="sum_ok">—</strong></article>
<article class="tile-ok"><header>избыточно (∞)</header><strong id="sum_inf">—</strong></article>
</section>
<div class="nfresh" hx-get="/admin/npr/api/freshness" hx-trigger="load, every 15s" hx-swap="none" hx-on::after-request="try{const d=JSON.parse(event.detail.xhr.responseText);const now=d.server_now?new Date(d.server_now):new Date();const tick=d.last_tick?new Date(d.last_tick):null;const ago=tick?Math.round((now-tick.getTime())/1000):null;const ok=ago!==null&&ago<90;document.getElementById('fresh_dot').style.background=ok?'#10b981':'#dc2626';document.getElementById('fresh_text').textContent=tick?('Последнее обновление из QUIK: '+tick.toLocaleString('ru-RU')+' ('+ago+' сек. назад)'):'Ждём первый тик от экспортёра…';const ev=d.last_event_ts?new Date(d.last_event_ts):null;document.getElementById('fresh_detail').textContent='События в истории: всего '+d.total_rows.toLocaleString('ru-RU')+' · за час: '+d.events_last_hour+' · за сутки: '+d.events_last_24h+(ev?' · последнее: '+ev.toLocaleString('ru-RU'):'');}catch(e){}">
<span id="fresh_dot" style="width:10px;height:10px;border-radius:50%;background:#9ca3af;display:inline-block"></span><span id="fresh_text">Проверяю актуальность данных…</span><span class="muted" id="fresh_detail" style="margin-left:auto"></span></div>
<form id="filters" class="nfilters" hx-get="/admin/npr/rows" hx-trigger="input changed delay:300ms, change" hx-target="#rows" hx-swap="innerHTML" onsubmit="return false">
<label>Поиск по коду клиента<input type="search" name="q" placeholder="например, BR22…" value="{{.Q}}"></label>
<label>Тип клиента<select name="mt" multiple size="3">{{range $v := list "МД" "МД+" "С"}}<option value="{{$v}}" {{if has $.MT $v}}selected{{end}}>{{$v}}</option>{{end}}</select></label>
<label>УДС не менее<input type="number" step="0.01" name="umn" value="{{.UMN}}"></label>
<label>УДС не более<input type="number" step="0.01" name="umx" value="{{.UMX}}"></label>
<label style="align-self:center;white-space:nowrap;display:flex;gap:6px;align-items:center"><input type="checkbox" name="only_calls" value="true" {{if .OnlyCalls}}checked{{end}} style="width:auto"> только маржин-колл</label>
<label>Сортировка<select name="sort">{{range $k, $t := dict "uds_asc" "УДС ↑ (опаснее сверху)" "uds_desc" "УДС ↓" "client_asc" "Код клиента ↑" "client_desc" "Код клиента ↓" "ts_desc" "Время обновления ↓"}}<option value="{{$k}}" {{if eq $.Sort $k}}selected{{end}}>{{$t}}</option>{{end}}</select></label>
<input type="hidden" name="page" value="{{.Page}}"><input type="hidden" name="tab" value="{{.Tab}}">
</form>
<div id="rows" hx-get="/admin/npr/rows" hx-trigger="load, every 60s" hx-include="#filters" hx-swap="innerHTML"><p class="muted"><em>Загружаю клиентов…</em></p></div>
{{end}}
@@ -1,47 +0,0 @@
{{define "content"}}
{{template "npr_css"}}
<p style="margin:0 0 12px;display:flex;align-items:center;gap:12px"><a href="/admin/npr" class="btn">← К списку клиентов</a>{{if not .Custom}}<span class="muted" id="autoupd" style="margin-left:auto">⟳ Страница обновится автоматически через 60 сек</span>{{end}}</p>
{{if not .Custom}}<script>(function(){let left=60;const el=document.getElementById('autoupd');const t=setInterval(function(){left-=1;if(el)el.textContent='⟳ Обновление через '+left+' сек';if(left<=0){clearInterval(t);location.reload();}},1000);})();</script>{{end}}
<div class="card">
<div style="display:flex;align-items:baseline;gap:12px;flex-wrap:wrap"><h2 style="margin:0">Клиент <code style="font-size:1.1em">{{.C.ClientCode}}</code></h2><span class="muted">Тип: <strong>{{if .C.MarginType}}{{.C.MarginType}}{{else}}—{{end}}</strong> · Данные на {{.C.TS.Local.Format "02.01.2006 15:04:05"}}</span></div>
<section class="nmetrics">
<article class="{{if or (eq .C.UDSFlag "CALL") (and .C.UDS (lt (deref .C.UDS) 1.0))}}m-call{{else if eq .C.UDSFlag "INF"}}m-ok{{end}}"><header>Уровень достаточности средств (УДС)</header><strong>{{if .C.UDS}}{{nprF .C.UDS}}{{else if eq .C.UDSFlag "INF"}}∞{{else if eq .C.UDSFlag "CALL"}}маржин-колл{{else}}—{{end}}</strong>{{if .C.BadgeClass}}<div style="margin-top:4px"><span class="badge {{.C.BadgeClass}}">{{.C.BadgeText}}</span></div>{{end}}</article>
<article><header>НПР1 (запас до начальной маржи)</header><strong>{{nprF .C.RCV1}}</strong></article>
<article><header>НПР2 (запас до минимальной маржи)</header><strong>{{nprF .C.RCV2}}</strong></article>
<article><header>Стоимость портфеля</header><strong>{{nprF .C.PortfolioValue}}</strong></article>
<article><header>Маржинальное требование</header><strong>{{nprF .C.MarginReq}}</strong></article>
</section>
<section style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:12px 0">
<div class="nmini"><header>Начальная маржа</header><strong>{{nprF .C.InitMargin}}</strong></div>
<div class="nmini"><header>Минимальная маржа</header><strong>{{nprF .C.MinMargin}}</strong></div>
<div class="nmini"><header>Скорректированная маржа</header><strong>{{nprF .C.CorrMargin}}</strong></div>
</section>
</div>
<div class="card">
<h3 style="margin:0 0 8px">История изменений</h3>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px">{{range .Periods}}<a class="btn{{if and (eq $.Hours .H) (not $.Custom)}} btn-ok{{end}}" href="?hours={{.H}}">{{.T}}</a>{{end}}<span class="muted" style="margin-left:auto">или свой диапазон (МСК):</span></div>
<form method="get" action="" style="display:flex;gap:8px;align-items:end;flex-wrap:wrap;margin-bottom:8px">
<label style="margin:0;font-size:12px">С<br><input type="datetime-local" name="dt_from" value="{{.DtFrom}}"></label>
<label style="margin:0;font-size:12px">По<br><input type="datetime-local" name="dt_to" value="{{.DtTo}}"></label>
<button type="submit" class="btn">Показать</button><a class="btn" href="?hours=24">Сбросить</a>
<a class="btn" style="margin-left:auto" href="/admin/npr/c/{{.C.ClientCode}}/export.csv?{{if .Custom}}dt_from={{.DtFrom}}&dt_to={{.DtTo}}{{else}}hours={{.Hours}}{{end}}">⤓ Скачать CSV (для регулятора)</a>
</form>
<p class="muted">Показано: с <strong>{{.TF.Local.Format "02.01.2006 15:04"}}</strong> по <strong>{{.TT.Local.Format "02.01.2006 15:04"}}</strong> (МСК).</p>
<canvas id="chart" style="max-height:340px;margin-bottom:14px"></canvas>
<h4 style="margin:0 0 6px">Последние {{len .History}} записей</h4>
<table class="ntable"><thead><tr><th>Время</th><th class="num">УДС</th><th>Статус</th><th class="num">НПР1</th><th class="num">НПР2</th><th class="num">Марж. требование</th></tr></thead>
<tbody>{{range .History}}<tr class="{{.Class}}"><td><small>{{.TS.Local.Format "02.01.2006 15:04:05"}}</small></td><td class="num">{{nprF .UDS}}</td><td>{{if .BadgeClass}}<span class="badge {{.BadgeClass}}">{{.BadgeText}}</span>{{else}}—{{end}}</td><td class="num">{{nprF .RCV1}}</td><td class="num">{{nprF .RCV2}}</td><td class="num">{{nprF .MarginReq}}</td></tr>{{end}}</tbody></table>
</div>
<script src="/admin/npr/static/chart.umd.min.js"></script>
<script>
fetch("/admin/npr/c/{{.C.ClientCode}}/series?{{if .Custom}}dt_from={{.DtFrom}}&dt_to={{.DtTo}}{{else}}hours={{.Hours}}{{end}}").then(r=>r.json()).then(d=>{
const labels=d.labels.map(s=>new Date(s).toLocaleString('ru-RU',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}));
new Chart(document.getElementById('chart'),{type:'line',data:{labels:labels,datasets:[
{label:'НПР1',data:d.rcv1,borderColor:'#059669',backgroundColor:'#05966920',tension:.2,spanGaps:true,yAxisID:'y',pointRadius:1.5},
{label:'НПР2',data:d.rcv2,borderColor:'#2563eb',backgroundColor:'#2563eb20',tension:.2,spanGaps:true,yAxisID:'y',pointRadius:1.5},
{label:'УДС',data:d.uds,borderColor:'#dc2626',backgroundColor:'#dc262620',tension:.2,spanGaps:true,yAxisID:'y2',pointRadius:1.5,borderDash:[5,3]}]},
options:{animation:false,responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},plugins:{legend:{position:'top'},tooltip:{callbacks:{label:c=>c.dataset.label+': '+(c.parsed.y===null?'—':c.parsed.y.toLocaleString('ru-RU',{maximumFractionDigits:2}))}}},
scales:{y:{position:'left',title:{display:true,text:'НПР'}},y2:{position:'right',title:{display:true,text:'УДС'},grid:{drawOnChartArea:false},suggestedMin:-1,suggestedMax:3}}}});
});
</script>
{{end}}
@@ -1,7 +0,0 @@
{{define "npr_rows"}}
<p class="muted">Найдено клиентов: <strong>{{.Total}}</strong>.{{if .Pages}} Страница <strong>{{.Page}}</strong> из <strong>{{.Pages}}</strong>.{{end}}</p>
<table class="ntable"><thead><tr><th>Код клиента</th><th>Тип</th><th class="num">УДС</th><th>Статус</th><th class="num">НПР1</th><th class="num">НПР2</th><th class="num">Марж. требование</th><th>Обновлено</th></tr></thead>
<tbody>{{range .Rows}}<tr class="{{.Class}}"><td><a href="/admin/npr/c/{{.ClientCode}}">{{.ClientCode}}</a></td><td>{{if .MarginType}}{{.MarginType}}{{else}}—{{end}}</td><td class="num"><strong>{{nprF .UDS}}</strong></td><td>{{if .BadgeClass}}<span class="badge {{.BadgeClass}}">{{.BadgeText}}</span>{{else}}—{{end}}</td><td class="num">{{nprF .RCV1}}</td><td class="num">{{nprF .RCV2}}</td><td class="num">{{nprF .MarginReq}}</td><td><small>{{.TS.Local.Format "15:04:05"}}</small></td></tr>{{end}}
{{if not .Rows}}<tr><td colspan="8" class="muted" style="text-align:center;padding:2rem"><em>Ни одного клиента под этот фильтр не найдено</em></td></tr>{{end}}</tbody></table>
<nav style="display:flex;gap:8px;justify-content:center;margin-top:10px">{{if gt .Page 1}}<button class="btn" hx-get="/admin/npr/rows" hx-include="#filters" hx-vals='{"page": {{add .Page -1}} }' hx-target="#rows">← Предыдущая</button>{{end}}{{if .HasNext}}<button class="btn" hx-get="/admin/npr/rows" hx-include="#filters" hx-vals='{"page": {{add .Page 1}} }' hx-target="#rows">Следующая →</button>{{end}}</nav>
{{end}}
@@ -1,14 +0,0 @@
{{define "npr_css"}}
<style>
.ntabs{display:flex;gap:4px;margin:0 0 10px;border-bottom:2px solid var(--border)}.ntabs a{padding:8px 14px;text-decoration:none;color:var(--text-2);border-bottom:2px solid transparent;margin-bottom:-2px;display:inline-flex;align-items:center;gap:6px;font-size:14px}.ntabs a.active{border-bottom-color:var(--accent);color:var(--accent-strong);font-weight:600}.ntabs .count{background:var(--card-2);color:var(--text-2);font-size:11px;padding:1px 7px;border-radius:10px;font-weight:600}
.tab-desc{font-size:13px;color:var(--muted);margin:0 0 10px}.nhelp{background:var(--card-2);padding:10px 14px;border-radius:6px;margin-bottom:12px;font-size:13px}.nhelp .legend{display:inline-flex;gap:12px;flex-wrap:wrap;margin-top:6px}.legend-item{display:inline-flex;align-items:center;gap:5px}.legend-box{display:inline-block;width:14px;height:14px;border-radius:3px;border:1px solid #999}
.nsummary{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:10px}.nsummary article{margin:0;padding:8px 10px;text-align:center;border:1px solid var(--border);border-radius:8px;background:var(--card)}.nsummary header{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em}.nsummary strong{font-size:24px;display:block;line-height:1.1}.tile-call strong{color:#b91c1c}.tile-warn strong{color:#b45309}.tile-ok strong{color:#047857}.tile-call{border-color:#fecaca!important;background:color-mix(in srgb,#fee2e2 50%,var(--card))!important}.tile-warn{border-color:#fde68a!important;background:color-mix(in srgb,#fef3c7 50%,var(--card))!important}.tile-ok{border-color:#d1fae5!important;background:color-mix(in srgb,#d1fae5 50%,var(--card))!important}
.nfresh{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:7px 12px;background:var(--card-2);border-radius:6px;font-size:13px;flex-wrap:wrap}
.nfilters{display:grid;grid-template-columns:2fr 1fr 1fr 1fr auto 1fr;gap:8px;align-items:end;padding:10px;background:var(--card-2);border-radius:8px;margin-bottom:12px}.nfilters label{font-size:12px;color:var(--muted);margin:0}.nfilters input,.nfilters select{margin:0;padding:5px 8px;font-size:13px;width:100%}
.ntable{width:100%;border-collapse:collapse;font-size:13px}.ntable th,.ntable td{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:middle}.ntable thead th{background:var(--card-2);font-size:11px;text-transform:uppercase;letter-spacing:.03em;text-align:left}.ntable tbody tr:hover{background:var(--card-2)}.ntable td a{text-decoration:none;font-weight:600;color:var(--accent-strong)}.num{text-align:right;font-variant-numeric:tabular-nums}
.row-call{background:color-mix(in srgb,#fee2e2 70%,var(--card))!important}.row-call td{color:#7f1d1d}.row-warn{background:color-mix(in srgb,#fef3c7 70%,var(--card))!important}.row-inf{background:color-mix(in srgb,#d1fae5 60%,var(--card))!important}
.badge{display:inline-block;padding:1px 8px;border-radius:3px;font-size:11px;font-weight:600}.badge-call{background:#dc2626;color:#fff}.badge-warn{background:#f59e0b;color:#fff}.badge-inf{background:#10b981;color:#fff}.badge-ok{background:var(--card-2);color:var(--text-2)}
.nmetrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin:12px 0 0}.nmetrics article{margin:0;padding:12px;text-align:center;border:1px solid var(--border);border-radius:8px}.nmetrics header{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px}.nmetrics strong{font-size:26px;display:block;font-variant-numeric:tabular-nums}.m-call strong{color:#b91c1c}.m-ok strong{color:#047857}.nmini{padding:10px;border:1px solid var(--border);border-radius:8px}.nmini header{font-size:11px;color:var(--muted);text-transform:uppercase}
@media (max-width:900px){.nsummary,.nmetrics{grid-template-columns:repeat(2,1fr)}.nfilters{grid-template-columns:1fr 1fr}}
</style>
{{end}}