Экспорт QUIK — отдельная служба bj-quikexport; модули, выделенные из ядра, ставятся с обновлением

bj-quikexport (modules/quikexport + cmd/bj-quikexport, 127.0.0.1:8084): управление ARQA
quikexport перенесено из bj-server. Настройки у службы в /var/lib/bj/.bj/quik-export/settings.json
(при первом запуске переносятся из setup.json), раздел отдаётся фрагментом с индикатором меню,
действия отвечают X-BJ-Flash. bj-server встраивает раздел в «Настройку», проксирует формы
/admin/setup/quikexport/… и передаёт лицензию (X-BJ-Licensed). Общая проверка служебного
ключа — modules/modkit (им пользуется и bj-npr), общее для прокси — lkgateway/modules.go.

Выделенные из ядра модули (bj-npr, bj-quikexport: Component.FromCore) при обновлении ставятся и
там, где их ещё нет, если входят в лицензию: release.MarkSplitModules, request.json "new",
root-установщик ставит и включает unit-файл из комплекта. Если в обновлении меняется сам
установщик, модули ставятся после перезапуска bj-server (ensureSplitModules).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 01:15:27 +03:00
parent 5527a464ca
commit 97e99b72c7
26 changed files with 1095 additions and 303 deletions
+58
View File
@@ -0,0 +1,58 @@
package lkgateway
// Службы модулей (bj-npr, bj-quikexport, …) — отдельные программы на локальных портах, которые
// обновляются независимо от ядра. bj-server проверяет вход, права и лицензию, передаёт службе
// служебный ключ (X-BJ-Module-Key) и встраивает её страницы в свой макет. Сами службы — modules/.
import (
"crypto/rand"
"encoding/hex"
"html/template"
"net/http"
"os"
"strings"
"sync"
)
const moduleKeyFile = "/var/lib/bj/.bj/module.key"
var moduleKeyMu sync.Mutex
// 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
}
// noRedirect — ответы служб (в т. ч. редиректы) передаются браузеру как есть.
func noRedirect(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
// 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>`)
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
+1 -44
View File
@@ -8,8 +8,6 @@ package lkgateway
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"io"
@@ -48,30 +46,7 @@ func nprAvailable(rc *RuntimeConfig) bool {
// ---- служба модуля ----
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
}
var nprHTTP = &http.Client{Timeout: 60 * time.Second, CheckRedirect: noRedirect}
func nprURL() string {
if u := strings.TrimSpace(os.Getenv("BJ_NPR_URL")); u != "" {
@@ -80,17 +55,6 @@ func nprURL() string {
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) {
@@ -156,13 +120,6 @@ func registerNPR(mux *http.ServeMux, a *admin, rc *RuntimeConfig) {
mux.HandleFunc("/admin/npr/", proxy)
}
func pickStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
// ---- настройки: проверка подключения ----
var (
+22
View File
@@ -30,3 +30,25 @@ func TestModulePageWrap(t *testing.T) {
t.Errorf("ожидался один документ, <html встречается %d раз", strings.Count(body, "<html"))
}
}
// Раздел «Экспорт QUIK» от службы встраивается в страницу настроек, индикатор меню — от службы.
func TestSetupPageEmbedsQuikExport(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
frag := `<h1>QUIK — Экспорт биржевой информации</h1><div id="qx-frag">раздел службы</div>`
render(rec, a.setup, SetupData{page: nowPage("Настройка", "setup"), AdvancedUnlocked: true, Modules: map[string]bool{"quik-export": true, "quik-mnp": true, "npr": true},
QuikExport: QuikExportView{HTML: template.HTML(frag), Ind: "warn"}})
if rec.Code != http.StatusOK {
t.Fatalf("код %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, frag) {
t.Error("в странице настроек нет раздела от службы")
}
if !strings.Contains(body, `Экспорт QUIK<span class="ind warn">`) {
t.Error("индикатор меню не взят у службы")
}
}
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
package lkgateway
// Модуль «Экспорт биржевой информации» (ARQA quikexport) работает отдельной службой bj-quikexport
// (modules/quikexport, обновляется независимо). Раздел остаётся на странице «Настройка»: bj-server
// получает от службы готовый HTML раздела и цвет индикатора меню, а формы раздела
// (/admin/setup/quikexport/…) проксирует службе и превращает её ответ X-BJ-Flash в обычное
// сообщение страницы настроек. Лицензию проверяет bj-server и передаёт в X-BJ-Licensed.
import (
"bytes"
"context"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// quikExportFeature — лицензионный признак модуля.
const quikExportFeature = "quik-export"
// Применение настроек ждёт root-воркер до 3 минут.
var quikExportHTTP = &http.Client{Timeout: 4 * time.Minute, CheckRedirect: noRedirect}
func quikExportURL() string {
if u := strings.TrimSpace(os.Getenv("BJ_QUIKEXPORT_URL")); u != "" {
return strings.TrimRight(u, "/")
}
return "http://127.0.0.1:8084"
}
// QuikExportView — раздел «Экспорт QUIK» на странице настроек.
type QuikExportView struct {
HTML template.HTML
Ind string // индикатор в меню: ok | warn | err | пусто
}
func quikExportDo(ctx context.Context, rc *RuntimeConfig, method, uri string, body io.Reader, contentType string) (*http.Response, error) {
key, err := moduleKey()
if err != nil {
return nil, fmt.Errorf("служебный ключ модулей: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, quikExportURL()+uri, body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("X-BJ-Module-Key", key)
lic := "0"
if moduleAllowed(rc, quikExportFeature) {
lic = "1"
}
req.Header.Set("X-BJ-Licensed", lic)
return quikExportHTTP.Do(req)
}
func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
uri := "/section"
if showRO {
uri += "?reveal_ro=1"
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := quikExportDo(ctx, rc, http.MethodGet, uri, nil, "")
if err == nil {
b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
resp.Body.Close()
switch {
case rerr != nil:
err = rerr
case resp.StatusCode != http.StatusOK:
err = fmt.Errorf("служба ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
default:
return QuikExportView{HTML: template.HTML(b), Ind: resp.Header.Get("X-BJ-Indicator")}
}
}
return QuikExportView{Ind: "err", HTML: template.HTML(`<h1>QUIK — Экспорт биржевой информации</h1>`) + moduleMessage(
"Служба модуля не отвечает",
"Служба bj-quikexport не запущена или ещё не установлена на этом сервере ("+err.Error()+").",
"Установка и обновление модуля: Настройка → Обновления. Состояние службы: systemctl status bj-quikexport. Сам экспортёр ARQA от этого не зависит и продолжает выгрузку.")}
}
// registerQuikExportSetup — формы раздела проксируются службе bj-quikexport.
func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) {
proxy := func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(body) == 0 && r.PostForm != nil && !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
body = []byte(r.PostForm.Encode()) // форму уже разобрали раньше
}
// setupFlash читает return_to/section из формы — возвращаем тело на место.
restore := func() { r.Body = io.NopCloser(bytes.NewReader(body)) }
resp, err := quikExportDo(r.Context(), h.rc, r.Method, r.URL.RequestURI(), bytes.NewReader(body), r.Header.Get("Content-Type"))
if err != nil {
if r.Method == http.MethodPost {
restore()
setupFlash(w, r, "Экспорт QUIK: служба bj-quikexport не отвечает — "+err.Error())
return
}
http.Error(w, "служба bj-quikexport не отвечает: "+err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
if f := resp.Header.Get("X-BJ-Flash"); f != "" {
msg, uerr := url.PathUnescape(f)
if uerr != nil {
msg = f
}
restore()
setupFlash(w, r, msg)
return
}
for _, k := range []string{"Content-Type", "Content-Disposition", "Cache-Control"} {
if v := resp.Header.Get(k); v != "" {
w.Header().Set(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
mux.HandleFunc("/admin/setup/quikexport", proxy)
mux.HandleFunc("/admin/setup/quikexport/", proxy)
}
+1 -1
View File
@@ -36,7 +36,7 @@ type Settings struct {
License LicenseSettings `json:"license"`
Branding BrandingSettings `json:"branding"`
NPR NPRSettings `json:"npr"`
QuikExport QuikExportSettings `json:"quik_export"`
QuikExport json.RawMessage `json:"quik_export,omitempty"` // устарело: настройки у службы bj-quikexport
Auth AuthSettings `json:"auth"`
LastTest *TestRunResult `json:"last_test,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
+70 -2
View File
@@ -95,13 +95,16 @@ func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateStatus, error) {
}
st.Available, st.Notes = m.Version, m.Notes
st.Components = release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
release.MarkSplitModules(st.Components, u.allowed)
var upd []string
avail := map[string]string{}
for _, c := range st.Components {
if c.Available != "" {
avail[c.Name] = c.Available
}
if c.Update {
if c.New {
upd = append(upd, c.Name+" "+c.Available+" (установка модуля)")
} else if c.Update {
upd = append(upd, c.Name+" "+c.Available)
}
}
@@ -150,6 +153,7 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
return "", fmt.Errorf("манифест: %w", err)
}
plan := release.Plan("", m, release.LoadInstalled(release.InstalledFile), BuildVersion)
release.MarkSplitModules(plan, u.allowed)
want := map[string]bool{}
for _, n := range names {
want[n] = true
@@ -167,6 +171,29 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
if len(selected) == 0 {
return "", fmt.Errorf("нечего обновлять: все установленные компоненты актуальны (релиз %s)", m.Version)
}
// Прежний root-установщик не умеет впервые ставить выделенные из ядра модули: пока он сам не
// обновлён, такие модули откладываются — их поставит ensureSplitModules после перезапуска.
installerOutdated := false
for _, c := range plan {
if c.Name == "bj-component-install" && c.Update {
installerOutdated = true
}
}
var deferred []string
if installerOutdated {
kept := selected[:0]
for _, c := range selected {
if c.New {
deferred = append(deferred, c.Name)
continue
}
kept = append(kept, c)
}
selected = kept
if len(selected) == 0 {
return "", fmt.Errorf("сначала обновите установщик обновлений (bj-component-install): текущий не ставит модули %s", strings.Join(deferred, ", "))
}
}
cleanupStaging(24 * time.Hour)
staging := filepath.Join(updateStagingDir, fmt.Sprintf("%d", time.Now().UnixNano()))
@@ -180,6 +207,7 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
var list []string
var req struct {
Names []string `json:"names"`
New []string `json:"new,omitempty"`
}
for _, c := range selected {
for _, a := range m.Artifacts {
@@ -190,6 +218,9 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
}
}
req.Names = append(req.Names, c.Name)
if c.New {
req.New = append(req.New, c.Name)
}
list = append(list, c.Name+" "+c.Available)
}
rb, _ := json.Marshal(req)
@@ -212,9 +243,42 @@ func (u *Updater) ApplyUpdate(ctx context.Context, names []string) (string, erro
}
msg += "; перезапущены службы: " + strings.Join(units, ", ")
}
if len(deferred) > 0 {
msg += "; модули " + strings.Join(deferred, ", ") + " будут установлены после перезапуска bj-server новым установщиком"
}
return msg, nil
}
// allowed — разрешён ли лицензией признак компонента.
func (u *Updater) allowed(feature string) bool { return moduleAllowed(u.rc, feature) }
// ensureSplitModules — выделенные из ядра модули, которые входят в лицензию, но ещё не стоят на этом
// сервере (например, ядро обновилось прежним установщиком), ставятся сами: без своих служб разделы
// этих модулей в bj не работают. Раз за запуск bj-server; ошибки — только в журнал и «Последнюю проверку».
func (u *Updater) ensureSplitModules(ctx context.Context) {
var names []string
for _, c := range release.Plan("", nil, release.LoadInstalled(release.InstalledFile), BuildVersion) {
if c.FromCore && !c.Present && u.allowed(c.Feature) {
names = append(names, c.Name)
}
}
if len(names) == 0 {
return
}
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
msg, err := u.ApplyUpdate(cctx, names)
if err != nil {
msg = "Установка модулей " + strings.Join(names, ", ") + " не выполнена: " + err.Error()
} else {
msg = "Установка модулей: " + msg
}
log.Printf("lk-gateway: %s", msg)
cfg := u.rc.Snapshot().Update
cfg.LastResult, cfg.LastCheck = msg, time.Now().UTC()
_ = u.rc.SaveUpdateSettings(cfg)
}
// updateComponentRows — компоненты для раздела «Обновления» без обращения к артефактории:
// установленное на этом сервере и версии из последней проверки.
func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
@@ -227,9 +291,12 @@ func updateComponentRows(rc *RuntimeConfig) ([]release.ComponentState, bool) {
r.Update = r.Available != "" && (r.Present || r.Core) && (r.Installed == "" || release.IsNewer(r.Available, r.Installed))
if r.Present || r.Core || r.Available != "" {
out = append(out, r)
any = any || r.Update
}
}
release.MarkSplitModules(out, func(f string) bool { return moduleAllowed(rc, f) })
for _, r := range out {
any = any || r.Update
}
return out, any
}
@@ -273,6 +340,7 @@ func (u *Updater) updateLoop(ctx context.Context) {
return
case <-time.After(time.Minute):
cleanupStaging(time.Hour)
u.ensureSplitModules(ctx)
check()
}
for {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
[connect]
trade-server-ip-address=127.0.0.1
auth-name=xxx
check-tmo=150
port=15100
user_name=xxx
user_password=xxx
AutoHeartBeat=1
[proxy]
use-proxy=0
proxy-server-ip-address=
proxy-port=
@@ -1,250 +0,0 @@
-- Миграция: схема npr с историей НПР1/НПР2/УДС через триггер на "Portfolio".
--
-- Применять ПОСЛЕ того, как таблицы QUIK-экспортёра развёрнуты штатным скриптом
-- quikexport_11/scripts/postgre/exporter_pg.sql
-- в ту же БД. В эталонной "Portfolio" уже есть все нужные колонки
-- (PortfolioValue, InitMargin, MinMargin, CorrectedMargin, FundsLevel, Status, RiskLevel),
-- поэтому отдельного ALTER TABLE не требуется.
--
-- Политика записи: триггер пишет в npr.history только когда
-- (а) клиент появился впервые, или
-- (б) сменилась «зона» УДС (CALL → WARN → OK → INF), или
-- (в) прошло более 30 мин с последней записи по этому клиенту (heartbeat).
-- Это резко сокращает объём истории — фиксируются моменты смены состояния,
-- а не каждый тик экспортёра. Дополняется политикой хранения (retention).
BEGIN;
-- (б) Схема для истории НПР/УДС.
CREATE SCHEMA IF NOT EXISTS npr;
-- (в) Таблица истории. Append-only: свой BIGSERIAL PK, без UNIQUE по клиенту.
CREATE TABLE IF NOT EXISTS npr.history (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(4) NOT NULL,
margin_type varchar(4) NULL,
portfolio_value decimal(28, 8) NULL,
init_margin float NULL,
min_margin float NULL,
corrected_margin float NULL,
rcv1 float NULL,
rcv2 float NULL,
funds_level decimal(19, 2) NULL,
status int NULL,
risk_level int NULL
);
-- (г) Индекс для выборок «история по клиенту за период».
CREATE INDEX IF NOT EXISTS ix_npr_hist_client_ts
ON npr.history (client_code, ts DESC);
-- «Пульс» от экспортёра: триггер обновляет last_tick на каждом тике (не чаще раза в 10 сек).
-- Нужен отдельно от истории, чтобы UI видел «данные от QUIK приходят» даже когда зоны не меняются.
CREATE TABLE IF NOT EXISTS npr.heartbeat (
id int PRIMARY KEY DEFAULT 1,
last_tick timestamptz NOT NULL DEFAULT NOW(),
tick_count bigint NOT NULL DEFAULT 0
);
INSERT INTO npr.heartbeat (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Кэш «последнее записанное состояние» — нужен триггеру, чтобы понять,
-- изменилась ли зона относительно ранее сохранённой строки. Одна запись на клиентский PK.
CREATE TABLE IF NOT EXISTS npr.last_seen (
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(12) NOT NULL,
tag varchar(4) NOT NULL,
last_ts timestamptz NOT NULL,
last_zone text NOT NULL,
last_uds numeric(19,2),
PRIMARY KEY (firm_id, client_code, limit_kind, curr_code, tag)
);
-- Зона УДС: CALL / WARN / OK / INF / UNKNOWN. Используется и триггером, и отчётами.
CREATE OR REPLACE FUNCTION npr._zone(fl numeric) RETURNS text AS $$
SELECT CASE
WHEN fl IS NULL THEN 'UNKNOWN'
WHEN fl >= 9.99 THEN 'INF'
WHEN fl <= -9.99 THEN 'CALL'
WHEN fl < 0 THEN 'CALL'
WHEN fl < 1 THEN 'CALL'
WHEN fl < 1.5 THEN 'WARN'
ELSE 'OK'
END;
$$ LANGUAGE sql IMMUTABLE;
-- (д) Триггер-функция: новая запись в history только при смене зоны или раз в 30 мин.
-- Фильтр МД / МД+ / С применяется сразу, чтобы не тратить ресурсы на остальных.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_to_history()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
DECLARE
new_zone text;
last_ts timestamptz;
last_zone text;
should_write boolean := false;
BEGIN
-- Пульс: обновляем не чаще раза в 10 сек, чтобы не делать тысяч UPDATE на один тик экспортёра.
UPDATE npr.heartbeat
SET last_tick = NOW(), tick_count = tick_count + 1
WHERE id = 1 AND last_tick < NOW() - INTERVAL '10 seconds';
IF NEW."MarginType" NOT IN ('МД','МД+','С') THEN
RETURN NEW;
END IF;
new_zone := npr._zone(NEW."FundsLevel");
SELECT ls.last_ts, ls.last_zone
INTO last_ts, last_zone
FROM npr.last_seen ls
WHERE ls.firm_id = NEW."FirmId"
AND ls.client_code = NEW."ClientCode"
AND ls.limit_kind = NEW."LimitKind"
AND ls.curr_code = NEW."CurrCode"
AND ls.tag = NEW."Tag";
IF last_ts IS NULL THEN
should_write := true; -- впервые видим клиента
ELSIF last_zone IS DISTINCT FROM new_zone THEN
should_write := true; -- переход зоны
ELSIF NOW() - last_ts > INTERVAL '30 minutes' THEN
should_write := true; -- heartbeat
END IF;
IF should_write THEN
INSERT INTO npr.history (
firm_id, client_code, limit_kind, curr_code, margin_type,
portfolio_value, init_margin, min_margin, corrected_margin,
rcv1, rcv2, funds_level, status, risk_level
) VALUES (
NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."MarginType",
NEW."PortfolioValue", NEW."InitMargin", NEW."MinMargin", NEW."CorrectedMargin",
NEW."RCV1", NEW."RCV2", NEW."FundsLevel", NEW."Status", NEW."RiskLevel"
);
INSERT INTO npr.last_seen (firm_id, client_code, limit_kind, curr_code, tag,
last_ts, last_zone, last_uds)
VALUES (NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."Tag",
NOW(), new_zone, NEW."FundsLevel")
ON CONFLICT (firm_id, client_code, limit_kind, curr_code, tag)
DO UPDATE SET last_ts = EXCLUDED.last_ts,
last_zone = EXCLUDED.last_zone,
last_uds = EXCLUDED.last_uds;
END IF;
RETURN NEW;
END;
$BODY$;
-- (е) Триггер на "Portfolio" — AFTER INSERT OR UPDATE.
DROP TRIGGER IF EXISTS portfolio_to_npr_history ON "Portfolio";
CREATE TRIGGER portfolio_to_npr_history
AFTER INSERT OR UPDATE ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_to_history();
-- (е2) Повторный INSERT в "Portfolio" → UPDATE без ошибки.
-- Экспортёр каждый цикл сначала делает подготовленный INSERT по всем строкам
-- и по дубликату ключа переходит к UPDATE. В PostgreSQL 11 каждая ошибка
-- подготовленного INSERT оставляет ~20 КБ в памяти сессии: ~10 тыс. строк/мин
-- дают ≈200 МБ/мин, backend дорастает до OOM и роняет весь кластер (14.09.2026).
-- AFTER UPDATE триггер истории выше срабатывает как обычно.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_insert_as_update()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
BEGIN
UPDATE "Portfolio"
SET "MarginType" = NEW."MarginType", "Status" = NEW."Status",
"PortfolioValue" = NEW."PortfolioValue", "InitMargin" = NEW."InitMargin",
"MinMargin" = NEW."MinMargin", "CorrectedMargin" = NEW."CorrectedMargin",
"RCV1" = NEW."RCV1", "RCV2" = NEW."RCV2",
"RiskLevel" = NEW."RiskLevel", "FundsLevel" = NEW."FundsLevel"
WHERE "FirmId" = NEW."FirmId" AND "ClientCode" = NEW."ClientCode"
AND "LimitKind" = NEW."LimitKind" AND "CurrCode" = NEW."CurrCode" AND "Tag" = NEW."Tag";
IF FOUND THEN
RETURN NULL;
END IF;
RETURN NEW;
END;
$BODY$;
DROP TRIGGER IF EXISTS portfolio_insert_as_update ON "Portfolio";
CREATE TRIGGER portfolio_insert_as_update
BEFORE INSERT ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_insert_as_update();
-- (ж) Прикладная вьюха: УДС с обработкой QUIK-сентинелов и маржинальное требование.
-- QUIK кладёт УДС в диапазон [-9.99, 9.99]: 9.99 = «бесконечность» (капнут сверху),
-- -9.99 = маржин-колл / принудзакрытие (капнут снизу).
CREATE OR REPLACE VIEW npr.v_npr AS
SELECT
ts,
firm_id,
client_code,
margin_type,
portfolio_value,
init_margin,
min_margin,
rcv1,
rcv2,
CASE WHEN funds_level >= 9.99 THEN NULL ELSE funds_level END AS uds,
CASE WHEN funds_level >= 9.99 THEN 'INF'
WHEN funds_level <= -9.99 THEN 'CALL'
ELSE NULL END AS uds_flag,
GREATEST(-rcv1, 0) AS margin_requirement
FROM npr.history;
COMMIT;
-- ===========================================================================
-- Retention-политика: cron-файл /etc/cron.d/npr-retention чистит записи > 2 лет
-- раз в месяц:
-- 0 3 1 * * postgres psql -d quik_export -c \
-- "DELETE FROM npr.history WHERE ts < NOW() - INTERVAL '2 years';"
-- Если нужно партиционирование по месяцам — отдельная миграция.
-- ===========================================================================
-- npr.v_current (текущее состояние по клиентам; снято с рабочей базы 14.09.2026)
CREATE OR REPLACE VIEW npr.v_current AS
SELECT p."FirmId" AS firm_id,
p."ClientCode" AS client_code,
p."LimitKind" AS limit_kind,
p."CurrCode" AS curr_code,
p."Tag" AS tag,
p."MarginType" AS margin_type,
p."RCV1" AS rcv1,
p."RCV2" AS rcv2,
p."FundsLevel" AS funds_level,
p."PortfolioValue" AS portfolio_value,
p."InitMargin" AS init_margin,
p."MinMargin" AS min_margin,
p."CorrectedMargin" AS corrected_margin,
p."Status" AS status,
p."RiskLevel" AS risk_level,
CASE
WHEN p."FundsLevel" >= 9.99 THEN NULL::numeric
ELSE p."FundsLevel"
END AS uds,
CASE
WHEN p."FundsLevel" >= 9.99 THEN 'INF'::text
WHEN p."FundsLevel" <= '-9.99'::numeric THEN 'CALL'::text
ELSE NULL::text
END AS uds_flag,
GREATEST(- p."RCV1", 0::double precision) AS margin_requirement,
npr._zone(p."FundsLevel") AS zone,
hb.last_tick AS ts
FROM "Portfolio" p
CROSS JOIN ( SELECT heartbeat.last_tick
FROM npr.heartbeat
WHERE heartbeat.id = 1) hb
WHERE p."MarginType"::text = ANY (ARRAY['МД'::text, 'МД+'::text, 'С'::text, 'C'::text]);
GRANT SELECT ON npr.v_current TO quik_ro;
@@ -8,7 +8,7 @@
<button data-sec="db"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5.5" rx="7" ry="2.6"/><path d="M5 5.5v13c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6v-13"/><path d="M5 12c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6"/></svg></span>База данных<span class="ind {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span></button>
{{if or .AdvancedUnlocked (index .Modules "quik-mnp")}}<button data-sec="fancy"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h12M12 4l4 4-4 4"/><path d="M20 16H8M12 20l-4-4 4-4"/></svg></span>Обмен Fancy<span class="ind {{if .Settings.Fancy.DepoTablesCheckOK}}ok{{else if .Settings.Fancy.Server}}warn{{end}}"></span></button>
<button data-sec="quik"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V4M4 20h16"/><rect x="7" y="12" width="2.6" height="5"/><rect x="12" y="8" width="2.6" height="9"/><rect x="17" y="14" width="2.6" height="3"/></svg></span>QUIK МНП<span class="ind {{if .Settings.Quik.Verified}}ok{{else if .Settings.Quik.Server}}warn{{end}}"></span></button>
<button data-sec="quikexport"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l5-5 4 4 7-8"/><path d="M14 8h6v6"/></svg></span>Экспорт QUIK<span class="ind {{if not .QuikExport.Licensed}}err{{else if and .QuikExport.S.Enabled (eq .QuikExport.Service.Active "active")}}ok{{else if .QuikExport.S.Enabled}}warn{{end}}"></span></button>{{end}}
<button data-sec="quikexport"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l5-5 4 4 7-8"/><path d="M14 8h6v6"/></svg></span>Экспорт QUIK<span class="ind {{.QuikExport.Ind}}"></span></button>{{end}}
<button data-sec="users"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.5"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><circle cx="17" cy="9" r="2.5"/><path d="M15.5 14.5c2.8.3 5.5 2.5 5.5 5.5"/></svg></span>Пользователи<span class="ind {{if .Users.Open}}warn{{else}}ok{{end}}"></span></button>
<button data-sec="crypto"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="nsd"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M4 21V10M20 21V10M3 10l9-6 9 6M8 21v-7M12 21v-7M16 21v-7"/></svg></span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
@@ -362,135 +362,7 @@
{{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}}
<section class="settings-section" id="sec-quikexport">
{{$X := .QuikExport}}{{$S := $X.S}}
<h1>QUIK — Экспорт биржевой информации</h1>
<p class="muted">Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак <code>quik-export</code>.</p>
{{range $X.Warnings}}<div class="card" style="padding:8px 14px;margin-bottom:8px;background:color-mix(in srgb,var(--warn) 10%,var(--card));display:flex;gap:8px;align-items:center"><span class="dot warn"></span>{{.}}</div>{{end}}
<div class="card">
<h2><span class="dot {{if not $X.Licensed}}err{{else if and $S.Enabled (eq $X.Service.Active "active")}}ok{{else if $S.Enabled}}warn{{else}}err{{end}}"></span>Состояние модуля</h2>
<form method="post" action="/admin/setup/quikexport" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="module">
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="enabled" value="1" {{if $S.Enabled}}checked{{end}} {{if not $X.Licensed}}disabled{{end}}> Модуль подключён</label>
<button type="submit" class="btn" {{if not $X.Licensed}}disabled{{end}}>Сохранить</button>
{{if not $X.Licensed}}<span class="muted">не входит в лицензию — см. раздел «Лицензия»</span>{{end}}
</form>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px">
<div class="nmini" style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Служба quikexport</div>
<strong>{{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}}</strong>
<div class="muted" style="font-size:11.5px">{{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}</div></div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Данные из QUIK</div>
{{if $X.DB.OK}}<strong>{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}}</strong>
<div class="muted" style="font-size:11.5px">{{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}</div>
{{else}}<strong style="color:var(--err)">база недоступна</strong><div class="muted" style="font-size:11.5px">{{$X.DB.Error}}</div>{{end}}</div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Объём базы {{$S.DBName}}</div>
<strong>{{printf "%.0f" $X.DB.DBSizeMB}} МБ</strong>
<div class="muted" style="font-size:11.5px">история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)</div></div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}</div>
{{with $X.DiskDB}}{{if .OK}}<strong style="{{if lt .FreeGB 10.0}}color:var(--err){{end}}">свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}</strong><div class="muted" style="font-size:11.5px">занято {{.UsedPct}}% · {{.Path}}</div>{{end}}{{end}}
{{if not $X.DiskDB.OK}}{{with $X.DiskExp}}<strong>свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}</strong><div class="muted" style="font-size:11.5px">занято {{.UsedPct}}%</div>{{end}}{{end}}</div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Логи и кэш экспортёра</div>
<strong>{{printf "%.0f" $X.LogsMB}} МБ логов</strong>
<div class="muted" style="font-size:11.5px">{{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий</div></div>
</div>
{{if $X.DB.Tables}}<details style="margin-top:10px"><summary class="muted" style="cursor:pointer">Крупнейшие таблицы базы</summary>
<table style="font-size:12px;margin-top:6px"><thead><tr><th>Таблица</th><th>Размер</th><th>Строк (оценка)</th></tr></thead><tbody>{{range $X.DB.Tables}}<tr><td><code>{{.Name}}</code></td><td>{{.Size}}</td><td>{{.Rows}}</td></tr>{{end}}</tbody></table></details>{{end}}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="restart"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>Перезапустить службу</button></form>
<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="{{if eq $X.Service.Active "active"}}stop{{else}}start{{end}}"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>{{if eq $X.Service.Active "active"}}Остановить{{else}}Запустить{{end}}</button></form>
{{if and $X.Service.Installed (not $X.Service.Enabled)}}<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="enable"><button class="btn">Включить автозапуск</button></form>{{end}}
</div>
{{if $X.LogTail}}<details style="margin-top:10px"><summary class="muted" style="cursor:pointer">Последние строки export.log{{if $X.ErrTail}} и export.err{{end}}</summary>
<pre style="font-size:11px;white-space:pre-wrap;max-height:260px;overflow:auto;background:var(--card-2);padding:8px;border-radius:6px">{{range $X.LogTail}}{{.}}
{{end}}{{if $X.ErrTail}}--- export.err ---
{{range $X.ErrTail}}{{.}}
{{end}}{{end}}</pre></details>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if and $S.QuikHost $S.QuikUser $S.KeysNames}}ok{{else}}warn{{end}}"></span>1. Подключение к серверу QUIK</h2>
<p class="muted">Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей <code>pubring.txk</code> / <code>secring.txk</code> к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;grid-template-columns:2fr 1fr;gap:10px;max-width:720px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="quik">
<label>Адрес сервера QUIK <input type="text" name="quik_host" value="{{$S.QuikHost}}" placeholder="91.209.122.88" style="width:100%" required></label>
<label>Порт <input type="number" name="quik_port" value="{{$S.QuikPort}}" style="width:100%"></label>
<label>Пользователь QUIK <input type="text" name="quik_user" value="{{$S.QuikUser}}" style="width:100%" required></label>
<label>Пароль <input type="password" name="quik_password" value="" placeholder="{{if $S.QuikPassword}}(сохранён, оставьте пустым){{else}}пароль{{end}}" style="width:100%"></label>
<label>Имя авторизации (auth-name) <input type="text" name="auth_name" value="{{$S.AuthName}}" placeholder="FontV" style="width:100%"></label>
<div style="align-self:end"><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
<form method="post" action="/admin/setup/quikexport/keys" enctype="multipart/form-data" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;max-width:720px;margin-top:14px;align-items:end">
<input type="hidden" name="section" value="sec-quikexport">
<label>Открытый ключ pubring.txk <input type="file" name="pubring" accept=".txk" style="width:100%"></label>
<label>Закрытый ключ secring.txk <input type="file" name="secring" accept=".txk" style="width:100%"></label>
<button type="submit" class="btn">Загрузить ключи</button>
</form>
<p class="muted" style="font-size:12px;margin-top:6px">Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}<code>{{.}}</code> {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}.
В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}</p>
</div>
<div class="card">
<h2><span class="dot {{if and $S.DBPassword $S.ROPassword}}ok{{else}}warn{{end}}"></span>2. База данных экспорта</h2>
<p class="muted">Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;max-width:720px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="db">
<div style="display:flex;gap:18px;flex-wrap:wrap">
<label style="display:flex;gap:6px;align-items:center;font-weight:600;margin:0"><input type="radio" name="db_mode" value="local" {{if ne $S.DBMode "external"}}checked{{end}} onchange="document.getElementById('qx-ext').style.display='none'"> Наш PostgreSQL (этот сервер)</label>
<label style="display:flex;gap:6px;align-items:center;font-weight:600;margin:0"><input type="radio" name="db_mode" value="external" {{if eq $S.DBMode "external"}}checked{{end}} onchange="document.getElementById('qx-ext').style.display='grid'"> Внешний сервер PostgreSQL</label>
</div>
<div id="qx-ext" style="display:{{if eq $S.DBMode "external"}}grid{{else}}none{{end}};grid-template-columns:2fr 1fr;gap:10px">
<label>Хост <input type="text" name="db_host" value="{{$S.DBHost}}" style="width:100%"></label>
<label>Порт <input type="number" name="db_port" value="{{$S.DBPort}}" style="width:100%"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px">
<label>База <input type="text" name="db_name" value="{{$S.DBName}}" style="width:100%"></label>
<label>Пользователь экспорта (запись) <input type="text" name="db_user" value="{{$S.DBUser}}" style="width:100%"></label>
<label>Пароль <input type="password" name="db_password" value="" placeholder="{{if $S.DBPassword}}(сохранён){{else}}задать{{end}}" style="width:100%"></label>
<label>Логин только для чтения <input type="text" name="ro_user" value="{{$S.ROUser}}" style="width:100%"></label>
<label>Пароль <input type="password" name="ro_password" value="" placeholder="{{if $S.ROPassword}}(сохранён){{else}}задать{{end}}" style="width:100%"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button>
<button type="submit" formaction="/admin/setup/quikexport/dbinit" class="btn" {{if eq $S.DBMode "external"}}disabled{{end}}>Создать базу и роли на этом сервере</button>
<a class="btn" href="/admin/setup/quikexport/sql">SQL для внешнего сервера</a></div>
</form>
<p class="muted" style="font-size:12px;margin-top:8px">Доступ для внешних систем и других модулей: <code>{{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}}</code>
{{if $X.ShowRO}}<a href="/admin/setup#quikexport">скрыть пароль</a>{{else}}<a href="/admin/setup?reveal_ro=1#quikexport">показать пароль</a>{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост <code>{{$X.ServerIP}}</code>, доступ по IP открывает администратор (pg_hba){{end}}.</p>
</div>
<div class="card">
<h2><span class="dot ok"></span>3. Объём данных</h2>
<p class="muted">Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме <code>npr</code>.</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="volume">
<div style="display:grid;gap:6px">{{range $X.Presets}}
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0"><input type="radio" name="preset" value="{{.Key}}" {{if eq $S.Preset .Key}}checked{{end}} onchange="document.getElementById('qx-tables').style.display=this.value==='custom'?'block':'none'"><span><b>{{.Title}}</b><br><span class="muted" style="font-size:12px">{{.Desc}}</span></span></label>{{end}}
</div>
<div id="qx-tables" style="display:{{if eq $S.Preset "custom"}}block{{else}}none{{end}};border:1px solid var(--border);border-radius:8px;padding:10px">
{{range $g := $X.Groups}}<div style="font-weight:600;margin:6px 0 4px">{{$g}}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:4px 12px">{{range $X.Tables}}{{if eq .Group $g}}
<label style="display:flex;gap:6px;align-items:flex-start;font-weight:400;margin:0;font-size:12.5px"><input type="checkbox" name="tables" value="{{.Name}}" {{if index $X.Enabled .Name}}checked{{end}}><span><code>{{.Name}}</code> — {{.Title}} <span class="muted">· объём: {{.Load}}{{if .Note}} · {{.Note}}{{end}}</span></span></label>{{end}}{{end}}</div>{{end}}
</div>
<details><summary class="muted" style="cursor:pointer">Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}<code>{{.}}</code> {{end}}</summary></details>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;max-width:820px">
<label>Период портфелей, с <input type="number" name="portfolio_period" min="10" value="{{$S.PortfolioPeriod}}" style="width:100%"></label>
<label>Период «Текущих торгов», с <input type="number" name="param_period" min="0" value="{{$S.ParamPeriod}}" style="width:100%"></label>
<label>Виды лимитов (LimitKinds) <input type="text" name="limit_kinds" value="{{$S.LimitKinds}}" style="width:100%"></label>
<label>Уровень лога (DebugLevel) <input type="number" name="debug_level" min="0" max="255" value="{{$S.DebugLevel}}" style="width:100%"></label>
</div>
<div><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
</div>
<div class="card">
<h2><span class="dot {{if not $S.AppliedAt.IsZero}}ok{{else}}warn{{end}}"></span>4. Применить к экспортёру</h2>
<p class="muted">bj собирает <code>export.ini</code>, <code>info.ini</code>, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы (<code>{{$S.Dir}}</code>) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<a class="btn" href="/admin/setup/quikexport/preview" target="_blank">Предпросмотр файлов</a>
<form method="post" action="/admin/setup/quikexport/apply" onsubmit="return confirm('Установить настройки в экспортёр и перезапустить службу? Выгрузка прервётся на несколько секунд.')"><input type="hidden" name="section" value="sec-quikexport"><button class="btn btn-ok" {{if not $X.Licensed}}disabled{{end}}>Применить настройки и перезапустить экспортёр</button></form>
<form method="post" action="/admin/setup/quikexport/import"><input type="hidden" name="section" value="sec-quikexport"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>Прочитать настройки из экспортёра</button></form>
</div>
<p class="muted" style="font-size:12px;margin-top:10px">Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в <code>{{$S.Dir}}</code>; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.</p>
</div>
{{.QuikExport.HTML}}
</section>
{{/* ============ КРИПТОГРАФИЯ ============ */}}
@@ -923,16 +795,16 @@
<div class="card">
<h2>Компоненты</h2>
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.</p>
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся — кроме выделенных из ядра (НПР / УДС, экспорт QUIK): если модуль входит в лицензию, он ставится вместе с обновлением, иначе его раздел перестал бы работать.</p>
<div style="overflow-x:auto"><table style="margin-top:8px">
<thead><tr><th>Компонент</th><th>Установлена</th><th>Доступна</th><th>Служба</th><th></th></tr></thead>
<tbody>
{{range .Components}}<tr>
<td><strong>{{.Title}}</strong><div class="muted" style="font-size:11px"><code>{{.Name}}</code></div></td>
<td>{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}<span class="muted">неизвестна</span>{{end}}{{else}}<span class="muted">не установлен</span>{{end}}</td>
<td>{{if .Available}}{{.Available}}{{if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
<td>{{if .Available}}{{.Available}}{{if .New}} <span class="hero-status warn">к установке</span>{{else if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
<td><code style="font-size:11px">{{.Unit}}</code></td>
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить компонент до {{.Available}}? Его служба перезапустится.');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">Обновить</button></form>{{end}}</td>
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('{{if .New}}Установить модуль {{.Available}}? Будет включена его служба.{{else}}Обновить компонент до {{.Available}}? Его служба перезапустится.{{end}}');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">{{if .New}}Установить{{else}}Обновить{{end}}</button></form>{{end}}</td>
</tr>{{end}}
</tbody>
</table></div>