Экспорт 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:
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user