Экспорт 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:
@@ -5,6 +5,7 @@
|
||||
// этот инструмент. Проверка и установка — release.InstallStaged: подпись манифеста ключом из
|
||||
// /etc/bj/update.pub (принадлежит root), sha256 файлов, только известные компоненты, без отката,
|
||||
// копия прежнего файла, перезапуск служб (ядро последним), записи в /var/lib/bj/.bj/components.json.
|
||||
// Выделенным из ядра модулям, которые ставятся впервые, ставит и включает unit-файл службы из комплекта.
|
||||
// Вывод — JSON для bj-server.
|
||||
//
|
||||
// bj-component-install <каталог подготовки>
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/deploy"
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
|
||||
)
|
||||
|
||||
@@ -30,7 +32,7 @@ func main() {
|
||||
if pub, err := release.LoadPublicKey(*pubPath); err != nil {
|
||||
res.Error = fmt.Sprintf("открытый ключ обновлений (%s): %v", *pubPath, err)
|
||||
} else {
|
||||
res = release.InstallStaged(fs.Arg(0), release.InstallOptions{Root: *root, PubKey: pub, Restart: !*noRestart})
|
||||
res = release.InstallStaged(fs.Arg(0), release.InstallOptions{Root: *root, PubKey: pub, Restart: !*noRestart, Units: deploy.Files})
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
fmt.Println(string(b))
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Command bj-quikexport — служба модуля «Экспорт биржевой информации» (modules/quikexport):
|
||||
// управление ARQA quikexport, обновляется независимо от ядра bj.
|
||||
//
|
||||
// Слушает только локальный адрес: раздел настроек показывает bj-server, который проверяет вход,
|
||||
// права и лицензию и передаёт запросы со служебным ключом. Привилегированные операции
|
||||
// (установка файлов в каталог экспортёра, служба, база) идут через root-воркер bj-keymedia-worker.
|
||||
//
|
||||
// bj-quikexport [-addr 127.0.0.1:8084] [-store /var/lib/bj/.bj/quik-export/settings.json]
|
||||
// [-setup /var/lib/bj/.bj/setup.json] [-key /var/lib/bj/.bj/module.key]
|
||||
// bj-quikexport -version
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/quikexport"
|
||||
)
|
||||
|
||||
// version — версия модуля (go build -ldflags "-X main.version=…").
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:8084", "адрес службы (только локальный)")
|
||||
store := flag.String("store", "/var/lib/bj/.bj/quik-export/settings.json", "настройки модуля")
|
||||
setup := flag.String("setup", "/var/lib/bj/.bj/setup.json", "настройки bj (перенос раздела quik_export при первом запуске)")
|
||||
key := flag.String("key", "/var/lib/bj/.bj/module.key", "служебный ключ модулей (создаёт bj-server)")
|
||||
showVersion := flag.Bool("version", false, "показать версию")
|
||||
flag.Parse()
|
||||
if *showVersion {
|
||||
fmt.Println("bj-quikexport", version)
|
||||
return
|
||||
}
|
||||
srv, err := quikexport.New(quikexport.Config{StorePath: *store, SetupPath: *setup, KeyPath: *key})
|
||||
if err != nil {
|
||||
log.Fatalf("bj-quikexport: %v", err)
|
||||
}
|
||||
log.Printf("bj-quikexport %s: слушаю %s", version, *addr)
|
||||
// WriteTimeout не задаём: применение настроек ждёт root-воркер до нескольких минут.
|
||||
hs := &http.Server{Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
if err := hs.ListenAndServe(); err != nil {
|
||||
log.Printf("bj-quikexport: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,5 +6,5 @@ import "embed"
|
||||
|
||||
// Files — linux/sbin/* (в /usr/local/sbin) и systemd/*.service (в /etc/systemd/system).
|
||||
//
|
||||
//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service
|
||||
//go:embed linux/sbin/* systemd/bj-server.service systemd/bj-crypto.service systemd/bj-keymedia.service systemd/bj-npr.service systemd/bj-quikexport.service
|
||||
var Files embed.FS
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
Description=Bridge&Join: модуль «Экспорт биржевой информации» (управление ARQA quikexport)
|
||||
Documentation=https://git.zetit.ru/zuevav/Bridge-and-Join-s
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=bj
|
||||
Group=bj
|
||||
ExecStart=/opt/bj/bj-quikexport -addr 127.0.0.1:8084 -store /var/lib/bj/.bj/quik-export/settings.json -setup /var/lib/bj/.bj/setup.json -key /var/lib/bj/.bj/module.key
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=append:/var/log/bj/bj-quikexport.log
|
||||
StandardError=append:/var/log/bj/bj-quikexport.err
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ReadOnlyPaths=/var/lib/bj
|
||||
# Настройки и ключи модуля и staging для root-воркера (systemd создаёт их с владельцем bj),
|
||||
# очередь запросов root-воркера (создаёт bj-keymedia) и журналы.
|
||||
StateDirectory=bj/.bj/quik-export bj/quik-export-apply
|
||||
StateDirectoryMode=0700
|
||||
ReadWritePaths=-/var/lib/bj/keymedia-requests /var/log/bj
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -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("индикатор меню не взят у службы")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,9 @@ type Component struct {
|
||||
Unit string // служба systemd, перезапускаемая после установки ("" — без перезапуска)
|
||||
Exec bool // исполняемый файл
|
||||
Core bool // ядро: ставится всегда; модули — только если уже установлены на этом сервере
|
||||
// FromCore — модуль выделен из bj-server: обновление ставит его и там, где его ещё нет, если он
|
||||
// входит в лицензию (иначе после обновления ядра раздел модуля перестал бы работать).
|
||||
FromCore bool
|
||||
// Feature — признак лицензии, без которого сервер обновлений не отдаёт файл компонента.
|
||||
Feature string
|
||||
}
|
||||
@@ -31,8 +34,8 @@ var Components = []Component{
|
||||
{Name: "bj-component-install", Title: "Установщик обновлений", Path: "/usr/local/sbin/bj-component-install", Exec: true, Core: true, Feature: "updates"},
|
||||
{Name: "crypto-service.jar", Title: "Криптосервис", Path: "/opt/bj/crypto-service.jar", Unit: "bj-crypto", Feature: "updates"},
|
||||
{Name: "bj-lk", Title: "Личный кабинет клиента", Path: "/opt/bj/bj-lk", Unit: "bj-lk", Exec: true, Feature: "web-cabinet"},
|
||||
{Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, Feature: "npr"},
|
||||
{Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true, Feature: "quik-export"},
|
||||
{Name: "bj-npr", Title: "Риск-монитор НПР / УДС", Path: "/opt/bj/bj-npr", Unit: "bj-npr", Exec: true, FromCore: true, Feature: "npr"},
|
||||
{Name: "bj-quikexport", Title: "Экспорт QUIK", Path: "/opt/bj/bj-quikexport", Unit: "bj-quikexport", Exec: true, FromCore: true, Feature: "quik-export"},
|
||||
}
|
||||
|
||||
// ComponentByName — компонент по имени артефакта.
|
||||
@@ -96,6 +99,7 @@ type ComponentState struct {
|
||||
Available string // версия в манифесте ("" — компонента нет в релизе)
|
||||
SameFile bool // установленный файл совпадает с файлом релиза
|
||||
Update bool // можно обновить
|
||||
New bool // модуль выделен из ядра и ставится впервые (FromCore)
|
||||
}
|
||||
|
||||
// Plan — что можно обновить. m == nil — только состояние установленного (без сети).
|
||||
@@ -133,6 +137,17 @@ func Plan(root string, m *Manifest, installed map[string]Installed, selfVersion
|
||||
return out
|
||||
}
|
||||
|
||||
// MarkSplitModules — отметить к установке выделенные из ядра модули (FromCore), которых ещё нет на
|
||||
// этом сервере, если они есть в релизе и разрешены лицензией (allowed — по признаку компонента).
|
||||
func MarkSplitModules(states []ComponentState, allowed func(feature string) bool) {
|
||||
for i := range states {
|
||||
s := &states[i]
|
||||
if s.FromCore && !s.Present && s.Available != "" && allowed(s.Feature) {
|
||||
s.Update, s.New = true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cachedHash — sha256 файла с кэшем по размеру и времени изменения: страница настроек
|
||||
// не пересчитывает десятки мегабайт на каждый показ.
|
||||
var hashCache = struct {
|
||||
|
||||
@@ -64,3 +64,24 @@ func TestPlanIndependentVersions(t *testing.T) {
|
||||
t.Fatalf("откат на 0.9.0 не должен предлагаться: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitModulesInstalledWithCore(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeComp(t, root, "bj-server", "core")
|
||||
m := &Manifest{Version: "2026.09.2", Artifacts: []Artifact{
|
||||
{Name: "bj-npr", Version: "0.1.0", SHA256: "n"},
|
||||
{Name: "bj-quikexport", Version: "0.1.0", SHA256: "q"},
|
||||
{Name: "bj-lk", Version: "0.2.0", SHA256: "l"},
|
||||
}}
|
||||
st := Plan(root, m, nil, "2.0.0")
|
||||
MarkSplitModules(st, func(f string) bool { return f == "npr" || f == "web-cabinet" })
|
||||
if s := state(st, "bj-npr"); !s.Update || !s.New {
|
||||
t.Fatalf("выделенный из ядра модуль по лицензии должен ставиться: %+v", s)
|
||||
}
|
||||
if s := state(st, "bj-quikexport"); s.Update || s.New {
|
||||
t.Fatalf("модуль без лицензии не ставится: %+v", s)
|
||||
}
|
||||
if s := state(st, "bj-lk"); s.Update || s.New {
|
||||
t.Fatalf("самостоятельный модуль обновлением не ставится: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -29,6 +30,7 @@ type InstallOptions struct {
|
||||
PubKey ed25519.PublicKey // открытый ключ подписи релизов
|
||||
Restart bool // перезапустить службы установленных компонентов (ядро последним)
|
||||
AllowNew bool // ставить модули, которых ещё нет на сервере (первичная установка)
|
||||
Units fs.FS // unit-файлы служб (systemd/<служба>.service) для впервые устанавливаемых модулей
|
||||
}
|
||||
|
||||
// InstallResult — итог установки (JSON для bj-server).
|
||||
@@ -68,6 +70,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
||||
res.Release = m.Version
|
||||
var req struct {
|
||||
Names []string `json:"names"`
|
||||
New []string `json:"new"` // выделенные из ядра модули, которые ставятся впервые
|
||||
}
|
||||
if err := readStagedJSON(staging, "request.json", &req); err != nil {
|
||||
return fail("запрос: %v", err)
|
||||
@@ -105,7 +108,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
||||
if art.Version == "" {
|
||||
art.Version = m.Version
|
||||
}
|
||||
if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew {
|
||||
if _, err := os.Stat(c.PathAt(opt.Root)); err != nil && !c.Core && !opt.AllowNew && !(c.FromCore && contains(req.New, name)) {
|
||||
return fail("%s не установлен на этом сервере: модуль ставится установщиком, а не обновлением", name)
|
||||
}
|
||||
if prev, ok := installed[name]; ok && prev.Version != "" && IsNewer(prev.Version, art.Version) {
|
||||
@@ -124,7 +127,7 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
||||
sort.SliceStable(jobs, func(i, j int) bool { return componentOrder(jobs[i].c.Name) < componentOrder(jobs[j].c.Name) })
|
||||
|
||||
ts := time.Now().Format("20060102-150405")
|
||||
var units []string
|
||||
var units, newUnits []string
|
||||
for _, j := range jobs {
|
||||
if err := installFile(j.file, j.c.PathAt(opt.Root), j.c.Exec, ts); err != nil {
|
||||
return fail("установка %s: %v", j.c.Name, err)
|
||||
@@ -133,6 +136,21 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
||||
res.Installed = append(res.Installed, map[string]string{"name": j.c.Name, "version": j.a.Version})
|
||||
if j.c.Unit != "" {
|
||||
units = append(units, j.c.Unit)
|
||||
added, err := ensureUnit(opt.Root, opt.Units, j.c.Unit)
|
||||
if err != nil {
|
||||
return fail("служба %s: %v", j.c.Unit, err)
|
||||
}
|
||||
if added {
|
||||
newUnits = append(newUnits, j.c.Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(newUnits) > 0 && opt.Root == "" {
|
||||
if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil {
|
||||
return fail("systemctl daemon-reload: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if out, err := exec.Command("systemctl", append([]string{"enable"}, newUnits...)...).CombinedOutput(); err != nil {
|
||||
return fail("включение служб %s: %v: %s", strings.Join(newUnits, ", "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
if err := SaveInstalled(installedPath, installed); err != nil {
|
||||
@@ -153,6 +171,31 @@ func InstallStaged(staging string, opt InstallOptions) (res InstallResult) {
|
||||
return res
|
||||
}
|
||||
|
||||
// ensureUnit — поставить unit-файл службы из комплекта, если его ещё нет (true — поставлен).
|
||||
func ensureUnit(root string, units fs.FS, unit string) (bool, error) {
|
||||
dst := filepath.Join(root, "/etc/systemd/system", unit+".service")
|
||||
if _, err := os.Stat(dst); err == nil || units == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, err := fs.ReadFile(units, "systemd/"+unit+".service")
|
||||
if err != nil {
|
||||
return false, nil // в комплекте нет — служба ставится установщиком
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func componentOrder(name string) int {
|
||||
for i, c := range Components {
|
||||
if c.Name == name {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func stageNPR(t *testing.T, priv ed25519.PrivateKey, req string) string {
|
||||
t.Helper()
|
||||
staging := t.TempDir()
|
||||
bin := filepath.Join(staging, "bj-npr")
|
||||
if err := os.WriteFile(bin, []byte("npr 0.1.0"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sha, size, err := HashFile(bin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &Manifest{Version: "2026.09.2", Channel: "beta", ReleasedAt: time.Now().UTC().Truncate(time.Second),
|
||||
Artifacts: []Artifact{{Name: "bj-npr", File: "bj-npr", Version: "0.1.0", SHA256: sha, Size: size, Exec: true}}}
|
||||
sm, err := Sign(m, priv, "main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(sm)
|
||||
if err := os.WriteFile(filepath.Join(staging, "manifest.json"), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(staging, "request.json"), []byte(req), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return staging
|
||||
}
|
||||
|
||||
// Выделенный из ядра модуль ставится впервые только по явному запросу (new) — вместе с unit-файлом.
|
||||
func TestInstallSplitModuleWithUnit(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
units := fstest.MapFS{"systemd/bj-npr.service": {Data: []byte("[Service]\nExecStart=/opt/bj/bj-npr\n")}}
|
||||
|
||||
root := t.TempDir()
|
||||
res := InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units})
|
||||
if res.OK || !strings.Contains(res.Error, "не установлен") {
|
||||
t.Fatalf("без new модуль не должен ставиться: %+v", res)
|
||||
}
|
||||
|
||||
res = InstallStaged(stageNPR(t, priv, `{"names":["bj-npr"],"new":["bj-npr"]}`), InstallOptions{Root: root, PubKey: pub, Units: units})
|
||||
if !res.OK {
|
||||
t.Fatalf("установка: %+v", res)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(root, "/opt/bj/bj-npr")); err != nil || fi.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("файл модуля: %v %v", fi, err)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(root, "/etc/systemd/system/bj-npr.service")); err != nil || !strings.Contains(string(b), "ExecStart=/opt/bj/bj-npr") {
|
||||
t.Fatalf("unit-файл: %q %v", b, err)
|
||||
}
|
||||
if inst := LoadInstalled(filepath.Join(root, InstalledFile)); inst["bj-npr"].Version != "0.1.0" {
|
||||
t.Fatalf("components.json: %+v", inst)
|
||||
}
|
||||
|
||||
// Самостоятельный модуль (не FromCore) по new не ставится.
|
||||
c, _ := ComponentByName("bj-lk")
|
||||
if c.FromCore {
|
||||
t.Fatal("bj-lk не выделялся из ядра")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package modkit — общее для служб модулей bj (bj-npr, bj-quikexport, …).
|
||||
//
|
||||
// Службы модулей слушают только локальный адрес и принимают запросы только от bj-server:
|
||||
// он проверяет вход, права и лицензию и передаёт служебный ключ в X-BJ-Module-Key
|
||||
// (файл /var/lib/bj/.bj/module.key создаёт bj-server, службы читают его от того же пользователя).
|
||||
package modkit
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RequireKey — пропускает запросы только со служебным ключом (кроме /healthz).
|
||||
// Ключ читается при каждом запросе, поэтому его замена не требует перезапуска службы.
|
||||
func RequireKey(keyPath string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key, err := os.ReadFile(keyPath)
|
||||
want := strings.TrimSpace(string(key))
|
||||
got := r.Header.Get("X-BJ-Module-Key")
|
||||
if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
|
||||
http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
+3
-20
@@ -11,7 +11,6 @@ package npr
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
@@ -29,6 +28,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit"
|
||||
)
|
||||
|
||||
//go:embed web/*.html
|
||||
@@ -148,24 +149,6 @@ func (s *Server) db() (*pgxpool.Pool, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// requireKey — принимать запросы только от bj-server (кроме /healthz).
|
||||
func (s *Server) requireKey(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
key, err := os.ReadFile(s.cfg.KeyPath)
|
||||
want := strings.TrimSpace(string(key))
|
||||
got := r.Header.Get("X-BJ-Module-Key")
|
||||
if err != nil || want == "" || subtle.ConstantTimeCompare([]byte(want), []byte(got)) != 1 {
|
||||
http.Error(w, "модуль принимает запросы только от bj-server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// page — полная страница фрагментом для макета bj-server.
|
||||
func (s *Server) page(w http.ResponseWriter, t *template.Template, name, title string, data any) {
|
||||
var buf bytes.Buffer
|
||||
@@ -597,5 +580,5 @@ func (s *Server) Handler() http.Handler {
|
||||
[]period{{1, "за 1 час"}, {6, "за 6 часов"}, {24, "за сутки"}, {168, "за 7 дней"}, {720, "за 30 дней"}}}
|
||||
s.page(w, s.client, "npr_client", "Клиент "+code+" · НПР / УДС", data)
|
||||
})
|
||||
return s.requireKey(mux)
|
||||
return modkit.RequireKey(s.cfg.KeyPath, mux)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package lkgateway
|
||||
package quikexport
|
||||
|
||||
// Модуль «Экспорт биржевой информации» — управление ARQA quikexport из bj:
|
||||
// ключи QUIK, логин/пароль, где держим базу (наш PostgreSQL или внешний),
|
||||
@@ -6,7 +6,10 @@ package lkgateway
|
||||
// свободное место и доступ для внешних систем. Файлы экспортёра принадлежат
|
||||
// arqasrv, служба — systemd, поэтому запись и перезапуск идут через root-воркер
|
||||
// (bj-keymedia-worker → /usr/local/sbin/bj-quikexport-admin), а чтение состояния
|
||||
// (systemctl show, размеры логов, статистика базы) bj делает сам.
|
||||
// (systemctl show, размеры логов, статистика базы) служба делает сама.
|
||||
//
|
||||
// Работает отдельной службой bj-quikexport (cmd/bj-quikexport, server.go), обновляется
|
||||
// независимо от ядра; раздел настроек показывает bj-server на странице «Настройка».
|
||||
//
|
||||
// Лицензионный признак модуля: quik-export.
|
||||
|
||||
@@ -147,14 +150,6 @@ func (s QuikExportSettings) RODSN(withPassword bool) string {
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s", s.ROUser, pw, s.dbHost(), s.DBPort, s.DBName)
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) UpdateQuikExport(s QuikExportSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.QuikExport = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// ---------- каталог таблиц экспорта ----------
|
||||
|
||||
// quikExportTable — одна таблица экспортёра ([Output tables] в export.ini).
|
||||
@@ -456,7 +451,7 @@ func quikExportRenderODBC(s QuikExportSettings) string {
|
||||
func quikExportRootOp(ctx context.Context, action, staging, extra string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||
defer cancel()
|
||||
return runKeymediaOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra})
|
||||
return rootOp(ctx, "quikexport-"+action, map[string]string{"staging": staging, "extra": extra})
|
||||
}
|
||||
|
||||
func quikExportNewStaging() (string, error) {
|
||||
@@ -468,8 +463,8 @@ func quikExportNewStaging() (string, error) {
|
||||
}
|
||||
|
||||
// quikExportApply — собирает файлы, кладёт в staging и просит root установить их.
|
||||
func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[string]any, error) {
|
||||
s := rc.Snapshot().QuikExport.withDefaults()
|
||||
func quikExportApply(ctx context.Context, st *Store, restart bool) (map[string]any, error) {
|
||||
s := st.Get()
|
||||
if s.QuikHost == "" || s.QuikUser == "" {
|
||||
return nil, fmt.Errorf("не заданы сервер QUIK и логин")
|
||||
}
|
||||
@@ -514,17 +509,17 @@ func quikExportApply(ctx context.Context, rc *RuntimeConfig, restart bool) (map[
|
||||
return res, err
|
||||
}
|
||||
s.AppliedAt = time.Now()
|
||||
_ = rc.UpdateQuikExport(s)
|
||||
_ = st.Save(s)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// quikExportImport — читает текущие export.ini/info.ini экспортёра и заполняет настройки.
|
||||
func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) {
|
||||
func quikExportImport(ctx context.Context, st *Store) (string, error) {
|
||||
res, err := quikExportRootOp(ctx, "dump", "", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := rc.Snapshot().QuikExport.withDefaults()
|
||||
s := st.Get()
|
||||
dec := func(k string) string {
|
||||
v, _ := res[k].(string)
|
||||
b, _ := base64.StdEncoding.DecodeString(v)
|
||||
@@ -610,7 +605,7 @@ func quikExportImport(ctx context.Context, rc *RuntimeConfig) (string, error) {
|
||||
}
|
||||
}
|
||||
s.Imported = true
|
||||
if err := rc.UpdateQuikExport(s); err != nil {
|
||||
if err := st.Save(s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("сервер QUIK %s:%d, пользователь %s, таблиц включено %d (%s), период портфелей %d с", s.QuikHost, s.QuikPort, s.QuikUser, len(s.Tables), s.Preset, s.PortfolioPeriod), nil
|
||||
@@ -737,10 +732,13 @@ func diskUsage(path string) diskInfo {
|
||||
|
||||
// quikExportDBStats — объём базы и скорость роста (логином только для чтения).
|
||||
type quikExportDBStats struct {
|
||||
OK bool
|
||||
Error string
|
||||
DBSizeMB float64
|
||||
Tables []struct{ Name, Size string; Rows int64 }
|
||||
OK bool
|
||||
Error string
|
||||
DBSizeMB float64
|
||||
Tables []struct {
|
||||
Name, Size string
|
||||
Rows int64
|
||||
}
|
||||
HistoryRows int64
|
||||
HistoryFrom time.Time
|
||||
Rows24h int64
|
||||
@@ -805,7 +803,10 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats {
|
||||
rows, err := p.Query(ctx, `SELECT s.schemaname||'.'||s.relname, pg_size_pretty(pg_total_relation_size(s.relid)), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid=s.relid),0) FROM pg_stat_user_tables s ORDER BY pg_total_relation_size(s.relid) DESC LIMIT 8`)
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var t struct{ Name, Size string; Rows int64 }
|
||||
var t struct {
|
||||
Name, Size string
|
||||
Rows int64
|
||||
}
|
||||
if rows.Scan(&t.Name, &t.Size, &t.Rows) == nil {
|
||||
st.Tables = append(st.Tables, t)
|
||||
}
|
||||
@@ -834,27 +835,27 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats {
|
||||
|
||||
// QuikExportView — данные раздела настроек.
|
||||
type QuikExportView struct {
|
||||
S QuikExportSettings
|
||||
Licensed bool
|
||||
Service quikExportServiceState
|
||||
Files []quikExportFileInfo // export.ini, info.ini, ключи
|
||||
Logs []quikExportFileInfo
|
||||
LogsMB float64
|
||||
QAPIMB float64
|
||||
DiskDB diskInfo
|
||||
DiskExp diskInfo
|
||||
DB quikExportDBStats
|
||||
LogTail []string
|
||||
ErrTail []string
|
||||
Tables []quikExportTable
|
||||
Groups []string
|
||||
Presets []struct{ Key, Title, Desc string }
|
||||
Enabled map[string]bool
|
||||
RODSN string
|
||||
RODSNFull string
|
||||
ShowRO bool
|
||||
ServerIP string
|
||||
Warnings []string
|
||||
S QuikExportSettings
|
||||
Licensed bool
|
||||
Service quikExportServiceState
|
||||
Files []quikExportFileInfo // export.ini, info.ini, ключи
|
||||
Logs []quikExportFileInfo
|
||||
LogsMB float64
|
||||
QAPIMB float64
|
||||
DiskDB diskInfo
|
||||
DiskExp diskInfo
|
||||
DB quikExportDBStats
|
||||
LogTail []string
|
||||
ErrTail []string
|
||||
Tables []quikExportTable
|
||||
Groups []string
|
||||
Presets []struct{ Key, Title, Desc string }
|
||||
Enabled map[string]bool
|
||||
RODSN string
|
||||
RODSNFull string
|
||||
ShowRO bool
|
||||
ServerIP string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -863,16 +864,16 @@ var (
|
||||
qxViewLast QuikExportView
|
||||
)
|
||||
|
||||
func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
|
||||
func quikExportView(st *Store, licensed, showRO bool) QuikExportView {
|
||||
qxViewMu.Lock()
|
||||
defer qxViewMu.Unlock()
|
||||
if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" {
|
||||
if time.Since(qxViewAt) < 15*time.Second && qxViewLast.S.Dir != "" && qxViewLast.Licensed == licensed {
|
||||
v := qxViewLast
|
||||
v.ShowRO = showRO
|
||||
return v
|
||||
}
|
||||
s := rc.Snapshot().QuikExport.withDefaults()
|
||||
v := QuikExportView{S: s, Licensed: moduleAllowed(rc, quikExportFeature), Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO}
|
||||
s := st.Get()
|
||||
v := QuikExportView{S: s, Licensed: licensed, Tables: quikExportTables, Presets: quikExportPresets, ShowRO: showRO}
|
||||
v.Enabled = map[string]bool{}
|
||||
for _, t := range s.Tables {
|
||||
v.Enabled[t] = true
|
||||
@@ -980,12 +981,12 @@ func quikExportServerIP() string {
|
||||
|
||||
// ---------- обработчики ----------
|
||||
|
||||
func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
func (srv *Server) saveQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().QuikExport.withDefaults()
|
||||
s := srv.store.Get()
|
||||
f := r.FormValue
|
||||
atoi := func(v string, def int) int {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 0 {
|
||||
@@ -1043,28 +1044,28 @@ func (h *setupHandlers) saveQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
s.Enabled = f("enabled") != ""
|
||||
}
|
||||
s = s.withDefaults()
|
||||
if err := h.rc.UpdateQuikExport(s); err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: ошибка сохранения: "+err.Error())
|
||||
if err := srv.store.Save(s); err != nil {
|
||||
flash(w, "Экспорт QUIK: ошибка сохранения: "+err.Error())
|
||||
return
|
||||
}
|
||||
quikExportInvalidate()
|
||||
setupFlash(w, r, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».")
|
||||
flash(w, "Экспорт QUIK: настройки сохранены. Чтобы они попали в экспортёр, нажмите «Применить настройки и перезапустить».")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) {
|
||||
func (srv *Server) uploadQuikExportKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error())
|
||||
flash(w, "Экспорт QUIK: не удалось прочитать файлы: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(quikExportKeysDir, 0o700); err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: "+err.Error())
|
||||
flash(w, "Экспорт QUIK: "+err.Error())
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().QuikExport.withDefaults()
|
||||
s := srv.store.Get()
|
||||
var saved []string
|
||||
for _, field := range []string{"pubring", "secring"} {
|
||||
file, hdr, err := r.FormFile(field)
|
||||
@@ -1078,13 +1079,13 @@ func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
name := field + ".txk"
|
||||
if err := os.WriteFile(filepath.Join(quikExportKeysDir, name), b, 0o600); err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: запись ключа: "+err.Error())
|
||||
flash(w, "Экспорт QUIK: запись ключа: "+err.Error())
|
||||
return
|
||||
}
|
||||
saved = append(saved, fmt.Sprintf("%s (%s, %d байт)", name, hdr.Filename, len(b)))
|
||||
}
|
||||
if len(saved) == 0 {
|
||||
setupFlash(w, r, "Экспорт QUIK: файлы ключей не выбраны.")
|
||||
flash(w, "Экспорт QUIK: файлы ключей не выбраны.")
|
||||
return
|
||||
}
|
||||
s.KeysAt = time.Now()
|
||||
@@ -1094,24 +1095,24 @@ func (h *setupHandlers) uploadQuikExportKeys(w http.ResponseWriter, r *http.Requ
|
||||
s.KeysNames = append(s.KeysNames, k)
|
||||
}
|
||||
}
|
||||
_ = h.rc.UpdateQuikExport(s)
|
||||
_ = srv.store.Save(s)
|
||||
quikExportInvalidate()
|
||||
setupFlash(w, r, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».")
|
||||
flash(w, "Экспорт QUIK: ключи загружены — "+strings.Join(saved, ", ")+". Они будут установлены в каталог экспортёра при «Применить настройки».")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
func (srv *Server) applyQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if !moduleAllowed(h.rc, quikExportFeature) {
|
||||
setupFlash(w, r, "Экспорт QUIK: модуль не входит в лицензию.")
|
||||
if !licensed(r) {
|
||||
flash(w, "Экспорт QUIK: модуль не входит в лицензию.")
|
||||
return
|
||||
}
|
||||
res, err := quikExportApply(r.Context(), h.rc, true)
|
||||
res, err := quikExportApply(r.Context(), srv.store, true)
|
||||
quikExportInvalidate()
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: применить не удалось — "+err.Error())
|
||||
flash(w, "Экспорт QUIK: применить не удалось — "+err.Error())
|
||||
return
|
||||
}
|
||||
applied, _ := res["applied"].([]any)
|
||||
@@ -1119,24 +1120,24 @@ func (h *setupHandlers) applyQuikExport(w http.ResponseWriter, r *http.Request)
|
||||
for _, a := range applied {
|
||||
names = append(names, fmt.Sprint(a))
|
||||
}
|
||||
setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"]))
|
||||
flash(w, fmt.Sprintf("Экспорт QUIK: установлено %s; резервная копия %v; служба: %v.", strings.Join(names, ", "), res["backup"], res["service"]))
|
||||
}
|
||||
|
||||
func (h *setupHandlers) importQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
func (srv *Server) importQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
msg, err := quikExportImport(r.Context(), h.rc)
|
||||
msg, err := quikExportImport(r.Context(), srv.store)
|
||||
quikExportInvalidate()
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: импорт не удался — "+err.Error())
|
||||
flash(w, "Экспорт QUIK: импорт не удался — "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg)
|
||||
flash(w, "Экспорт QUIK: настройки прочитаны из экспортёра — "+msg)
|
||||
}
|
||||
|
||||
func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
func (srv *Server) serviceQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -1145,35 +1146,35 @@ func (h *setupHandlers) serviceQuikExport(w http.ResponseWriter, r *http.Request
|
||||
switch op {
|
||||
case "start", "stop", "restart", "enable":
|
||||
default:
|
||||
setupFlash(w, r, "Экспорт QUIK: неизвестная операция.")
|
||||
flash(w, "Экспорт QUIK: неизвестная операция.")
|
||||
return
|
||||
}
|
||||
_, err := quikExportRootOp(r.Context(), "service", "", op)
|
||||
quikExportInvalidate()
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: служба — "+err.Error())
|
||||
flash(w, "Экспорт QUIK: служба — "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "Экспорт QUIK: операция «"+op+"» выполнена.")
|
||||
flash(w, "Экспорт QUIK: операция «"+op+"» выполнена.")
|
||||
}
|
||||
|
||||
func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
func (srv *Server) dbInitQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().QuikExport.withDefaults()
|
||||
s := srv.store.Get()
|
||||
if s.DBMode == "external" {
|
||||
setupFlash(w, r, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).")
|
||||
flash(w, "Экспорт QUIK: для внешней базы создайте базу и роли на том сервере (SQL-скрипты: /admin/setup/quikexport/sql).")
|
||||
return
|
||||
}
|
||||
if s.DBPassword == "" || s.ROPassword == "" {
|
||||
setupFlash(w, r, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.")
|
||||
flash(w, "Экспорт QUIK: задайте пароли пользователя экспорта и логина только для чтения.")
|
||||
return
|
||||
}
|
||||
dir, err := quikExportNewStaging()
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: "+err.Error())
|
||||
flash(w, "Экспорт QUIK: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
@@ -1186,15 +1187,15 @@ func (h *setupHandlers) dbInitQuikExport(w http.ResponseWriter, r *http.Request)
|
||||
res, err := quikExportRootOp(r.Context(), "db-init", dir, "")
|
||||
quikExportInvalidate()
|
||||
if err != nil {
|
||||
setupFlash(w, r, "Экспорт QUIK: база — "+err.Error())
|
||||
flash(w, "Экспорт QUIK: база — "+err.Error())
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"]))
|
||||
flash(w, fmt.Sprintf("Экспорт QUIK: база готова — %v", res["steps"]))
|
||||
}
|
||||
|
||||
// sqlQuikExport — отдаёт SQL для ручного создания базы на внешнем сервере.
|
||||
func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
s := h.rc.Snapshot().QuikExport.withDefaults()
|
||||
func (srv *Server) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
s := srv.store.Get()
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="quik_export_setup.sql"`)
|
||||
fmt.Fprintf(w, "-- База экспорта QUIK для bj. Выполнить на сервере PostgreSQL %s:%d от суперпользователя.\n", s.DBHost, s.DBPort)
|
||||
@@ -1208,8 +1209,8 @@ func (h *setupHandlers) sqlQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// previewQuikExport — показать export.ini/info.ini, которые будут установлены (пароли скрыты).
|
||||
func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
s := h.rc.Snapshot().QuikExport.withDefaults()
|
||||
func (srv *Server) previewQuikExport(w http.ResponseWriter, r *http.Request) {
|
||||
s := srv.store.Get()
|
||||
exp, err := quikExportRenderExportINI(s)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
@@ -1227,14 +1228,3 @@ func (h *setupHandlers) previewQuikExport(w http.ResponseWriter, r *http.Request
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprintf(w, "===== info.ini =====\n%s\n\n===== /etc/odbc.ini (секция) =====\n%s\n\n===== export.ini =====\n%s", mask(inf), quikExportRenderODBC(s), mask(exp))
|
||||
}
|
||||
|
||||
func registerQuikExportSetup(mux *http.ServeMux, h *setupHandlers) {
|
||||
mux.HandleFunc("/admin/setup/quikexport", h.saveQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/keys", h.uploadQuikExportKeys)
|
||||
mux.HandleFunc("/admin/setup/quikexport/apply", h.applyQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/import", h.importQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/service", h.serviceQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/dbinit", h.dbInitQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/sql", h.sqlQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/preview", h.previewQuikExport)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package quikexport
|
||||
|
||||
// Служба bj-quikexport: свои настройки, раздел настроек фрагментом для bj-server и действия раздела.
|
||||
//
|
||||
// Протокол с bj-server (всё со служебным ключом, см. modkit):
|
||||
// - GET /section[?reveal_ro=1] — HTML раздела «Экспорт QUIK» для страницы «Настройка»,
|
||||
// в заголовке X-BJ-Indicator — цвет индикатора меню (ok | warn | err | пусто);
|
||||
// - POST /admin/setup/quikexport[/keys|apply|import|service|dbinit] — действие; итог — 204
|
||||
// с сообщением в X-BJ-Flash, bj-server показывает его как обычное сообщение страницы настроек;
|
||||
// - GET /admin/setup/quikexport/sql и /preview — файлы как есть.
|
||||
//
|
||||
// Лицензию проверяет bj-server и передаёт в X-BJ-Licensed: 1.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.zetit.ru/zuevav/Bridge-and-Join-s/modules/modkit"
|
||||
)
|
||||
|
||||
//go:embed web/section.html
|
||||
var sectionFS embed.FS
|
||||
|
||||
// Config — где служба берёт настройки и ключ.
|
||||
type Config struct {
|
||||
StorePath string // собственные настройки модуля
|
||||
SetupPath string // setup.json bj: отсюда настройки переносятся при первом запуске (раздел quik_export)
|
||||
KeyPath string // служебный ключ модулей
|
||||
}
|
||||
|
||||
// Server — служба модуля.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
section *template.Template
|
||||
}
|
||||
|
||||
// New — служба с разобранным шаблоном; при первом запуске переносит настройки из setup.json.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
t, err := template.New("").Funcs(template.FuncMap{
|
||||
"msk": func(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
return t.In(time.FixedZone("МСК", 3*3600)).Format("02.01.2006 15:04:05")
|
||||
},
|
||||
}).ParseFS(sectionFS, "web/section.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &Store{path: cfg.StorePath}
|
||||
moved, err := st.migrate(cfg.SetupPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("перенос настроек из %s: %w", cfg.SetupPath, err)
|
||||
}
|
||||
if moved {
|
||||
log.Printf("bj-quikexport: настройки перенесены из %s в %s", cfg.SetupPath, cfg.StorePath)
|
||||
}
|
||||
return &Server{cfg: cfg, store: st, section: t}, nil
|
||||
}
|
||||
|
||||
// Handler — все маршруты службы; без служебного ключа отвечает 403.
|
||||
func (srv *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
mux.HandleFunc("/section", func(w http.ResponseWriter, r *http.Request) {
|
||||
v := quikExportView(srv.store, licensed(r), r.URL.Query().Get("reveal_ro") == "1")
|
||||
var buf bytes.Buffer
|
||||
if err := srv.section.ExecuteTemplate(&buf, "section", v); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-BJ-Indicator", indicator(v))
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
})
|
||||
mux.HandleFunc("/admin/setup/quikexport", srv.saveQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/keys", srv.uploadQuikExportKeys)
|
||||
mux.HandleFunc("/admin/setup/quikexport/apply", srv.applyQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/import", srv.importQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/service", srv.serviceQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/dbinit", srv.dbInitQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/sql", srv.sqlQuikExport)
|
||||
mux.HandleFunc("/admin/setup/quikexport/preview", srv.previewQuikExport)
|
||||
return modkit.RequireKey(srv.cfg.KeyPath, mux)
|
||||
}
|
||||
|
||||
func licensed(r *http.Request) bool { return r.Header.Get("X-BJ-Licensed") == "1" }
|
||||
|
||||
// flash — итог действия для страницы настроек bj.
|
||||
func flash(w http.ResponseWriter, msg string) {
|
||||
w.Header().Set("X-BJ-Flash", url.PathEscape(msg))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// indicator — цвет индикатора раздела в меню настроек.
|
||||
func indicator(v QuikExportView) string {
|
||||
switch {
|
||||
case !v.Licensed:
|
||||
return "err"
|
||||
case v.S.Enabled && v.Service.Active == "active":
|
||||
return "ok"
|
||||
case v.S.Enabled:
|
||||
return "warn"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- настройки ----
|
||||
|
||||
// Store — настройки модуля в собственном файле (bj-server перезаписывает setup.json целиком,
|
||||
// поэтому служба туда не пишет).
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Get — настройки со значениями по умолчанию.
|
||||
func (st *Store) Get() QuikExportSettings {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
var s QuikExportSettings
|
||||
if b, err := os.ReadFile(st.path); err == nil {
|
||||
_ = json.Unmarshal(b, &s)
|
||||
}
|
||||
return s.withDefaults()
|
||||
}
|
||||
|
||||
// Save — атомарная запись (0600: в настройках пароли).
|
||||
func (st *Store) Save(s QuikExportSettings) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
raw, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(st.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := st.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, st.path)
|
||||
}
|
||||
|
||||
// migrate — если своих настроек ещё нет, берёт раздел quik_export из setup.json bj.
|
||||
func (st *Store) migrate(setupPath string) (bool, error) {
|
||||
if _, err := os.Stat(st.path); err == nil || setupPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
b, err := os.ReadFile(setupPath)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
var sj struct {
|
||||
QuikExport json.RawMessage `json:"quik_export"`
|
||||
}
|
||||
if json.Unmarshal(b, &sj) != nil || len(sj.QuikExport) == 0 || string(sj.QuikExport) == "null" {
|
||||
return false, nil
|
||||
}
|
||||
var s QuikExportSettings
|
||||
if err := json.Unmarshal(sj.QuikExport, &s); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, st.Save(s)
|
||||
}
|
||||
|
||||
// ---- root-воркер ----
|
||||
|
||||
const keymediaReqDir = "/var/lib/bj/keymedia-requests"
|
||||
|
||||
// rootOp — запрос root-воркеру bj-keymedia-worker через файловую очередь и ожидание ответа.
|
||||
func rootOp(ctx context.Context, action string, fields map[string]string) (map[string]any, error) {
|
||||
id := fmt.Sprintf("%s-%d", action, time.Now().UnixNano())
|
||||
reqPath := filepath.Join(keymediaReqDir, id+".req")
|
||||
resPath := filepath.Join(keymediaReqDir, id+".res")
|
||||
defer os.Remove(resPath)
|
||||
|
||||
req := map[string]string{"action": action}
|
||||
for k, v := range fields {
|
||||
req[k] = v
|
||||
}
|
||||
reqBody, _ := json.Marshal(req)
|
||||
// Пишем атомарно (tmp → rename), чтобы воркер не подхватил полупустой файл.
|
||||
tmp := reqPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, reqBody, 0o660); err != nil {
|
||||
return nil, fmt.Errorf("запись запроса: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, reqPath); err != nil {
|
||||
return nil, fmt.Errorf("публикация запроса: %w", err)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("таймаут ожидания root-воркера")
|
||||
case <-ticker.C:
|
||||
b, err := os.ReadFile(resPath)
|
||||
if err != nil {
|
||||
continue // ещё не готово
|
||||
}
|
||||
res := map[string]any{}
|
||||
// systemd/скрипты могут напечатать предупреждение перед JSON — берём с первой «{».
|
||||
if i := bytes.IndexByte(b, '{'); i > 0 {
|
||||
b = b[i:]
|
||||
}
|
||||
if jerr := json.Unmarshal(b, &res); jerr != nil {
|
||||
return nil, fmt.Errorf("разбор ответа воркера: %v (%s)", jerr, strings.TrimSpace(string(b)))
|
||||
}
|
||||
if res["ok"] != true {
|
||||
msg, _ := res["error"].(string)
|
||||
if msg == "" {
|
||||
msg = "ошибка"
|
||||
}
|
||||
return res, fmt.Errorf("воркер: %s", msg)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package quikexport
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testKey = "k-0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestServiceSectionAndSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keyPath := filepath.Join(dir, "module.key")
|
||||
setup := filepath.Join(dir, "setup.json")
|
||||
store := filepath.Join(dir, "quik-export", "settings.json")
|
||||
if err := os.WriteFile(keyPath, []byte(testKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(setup, []byte(`{"quik_export":{"enabled":true,"quik_host":"10.0.0.1","quik_user":"exp","preset":"npr"}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := New(Config{StorePath: store, SetupPath: setup, KeyPath: keyPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := srv.store.Get(); got.QuikHost != "10.0.0.1" || !got.Enabled || got.QuikPort != 15100 {
|
||||
t.Fatalf("перенос настроек из setup.json: %+v", got)
|
||||
}
|
||||
h := srv.Handler()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/section", nil))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("без ключа: код %d", rec.Code)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/section", nil)
|
||||
req.Header.Set("X-BJ-Module-Key", testKey)
|
||||
req.Header.Set("X-BJ-Licensed", "1")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("раздел: код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Подключение к серверу QUIK", `value="10.0.0.1"`, "/admin/setup/quikexport/apply"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в разделе нет %q", want)
|
||||
}
|
||||
}
|
||||
if ind := rec.Header().Get("X-BJ-Indicator"); ind == "err" {
|
||||
t.Errorf("индикатор при действующей лицензии: %q", ind)
|
||||
}
|
||||
|
||||
form := url.Values{"part": {"quik"}, "quik_host": {"10.0.0.2"}, "quik_user": {"exp"}, "section": {"sec-quikexport"}}
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/setup/quikexport", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-BJ-Module-Key", testKey)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent || rec.Header().Get("X-BJ-Flash") == "" {
|
||||
t.Fatalf("сохранение: код %d, flash %q", rec.Code, rec.Header().Get("X-BJ-Flash"))
|
||||
}
|
||||
if got := srv.store.Get(); got.QuikHost != "10.0.0.2" {
|
||||
t.Fatalf("после сохранения quik_host = %q", got.QuikHost)
|
||||
}
|
||||
if fi, err := os.Stat(store); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("файл настроек: %v %v", fi, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
{{define "section"}}
|
||||
{{$X := .}}{{$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>
|
||||
{{end}}
|
||||
@@ -6,7 +6,7 @@
|
||||
# канал stable (по умолчанию) | beta | …
|
||||
# опубликовать publish — сразу загрузить на сервер (нужен репозиторий lic_Bridge-and-Join-s рядом)
|
||||
#
|
||||
# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION,
|
||||
# Версии компонентов: переменные BJ_SERVER_VERSION, INSTALLER_VERSION, COMPONENT_INSTALL_VERSION, NPR_VERSION, QUIKEXPORT_VERSION,
|
||||
# CRYPTO_VERSION (по умолчанию — версия релиза). Криптосервис берётся из CRYPTO_JAR
|
||||
# (по умолчанию /opt/bj/crypto-service.jar). Ключ подписи: RELEASE_KEY (~/keys/release-signing.priv).
|
||||
set -eu
|
||||
@@ -26,6 +26,8 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.installerVersion=$(ver
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$DIST/bj-component-install" ./cmd/bj-component-install
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver NPR_VERSION)" -o "$DIST/bj-npr" ./cmd/bj-npr
|
||||
ver NPR_VERSION > "$DIST/bj-npr.version"
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=$(ver QUIKEXPORT_VERSION)" -o "$DIST/bj-quikexport" ./cmd/bj-quikexport
|
||||
ver QUIKEXPORT_VERSION > "$DIST/bj-quikexport.version"
|
||||
ver BJ_SERVER_VERSION > "$DIST/bj-server.version"
|
||||
ver INSTALLER_VERSION > "$DIST/bj-installer.version"
|
||||
ver COMPONENT_INSTALL_VERSION > "$DIST/bj-component-install.version"
|
||||
|
||||
Reference in New Issue
Block a user