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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-16 01:15:27 +03:00
parent 5527a464ca
commit 97e99b72c7
26 changed files with 1095 additions and 303 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,13 +0,0 @@
[connect]
trade-server-ip-address=127.0.0.1
auth-name=xxx
check-tmo=150
port=15100
user_name=xxx
user_password=xxx
AutoHeartBeat=1
[proxy]
use-proxy=0
proxy-server-ip-address=
proxy-port=
@@ -1,250 +0,0 @@
-- Миграция: схема npr с историей НПР1/НПР2/УДС через триггер на "Portfolio".
--
-- Применять ПОСЛЕ того, как таблицы QUIK-экспортёра развёрнуты штатным скриптом
-- quikexport_11/scripts/postgre/exporter_pg.sql
-- в ту же БД. В эталонной "Portfolio" уже есть все нужные колонки
-- (PortfolioValue, InitMargin, MinMargin, CorrectedMargin, FundsLevel, Status, RiskLevel),
-- поэтому отдельного ALTER TABLE не требуется.
--
-- Политика записи: триггер пишет в npr.history только когда
-- (а) клиент появился впервые, или
-- (б) сменилась «зона» УДС (CALL → WARN → OK → INF), или
-- (в) прошло более 30 мин с последней записи по этому клиенту (heartbeat).
-- Это резко сокращает объём истории — фиксируются моменты смены состояния,
-- а не каждый тик экспортёра. Дополняется политикой хранения (retention).
BEGIN;
-- (б) Схема для истории НПР/УДС.
CREATE SCHEMA IF NOT EXISTS npr;
-- (в) Таблица истории. Append-only: свой BIGSERIAL PK, без UNIQUE по клиенту.
CREATE TABLE IF NOT EXISTS npr.history (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(4) NOT NULL,
margin_type varchar(4) NULL,
portfolio_value decimal(28, 8) NULL,
init_margin float NULL,
min_margin float NULL,
corrected_margin float NULL,
rcv1 float NULL,
rcv2 float NULL,
funds_level decimal(19, 2) NULL,
status int NULL,
risk_level int NULL
);
-- (г) Индекс для выборок «история по клиенту за период».
CREATE INDEX IF NOT EXISTS ix_npr_hist_client_ts
ON npr.history (client_code, ts DESC);
-- «Пульс» от экспортёра: триггер обновляет last_tick на каждом тике (не чаще раза в 10 сек).
-- Нужен отдельно от истории, чтобы UI видел «данные от QUIK приходят» даже когда зоны не меняются.
CREATE TABLE IF NOT EXISTS npr.heartbeat (
id int PRIMARY KEY DEFAULT 1,
last_tick timestamptz NOT NULL DEFAULT NOW(),
tick_count bigint NOT NULL DEFAULT 0
);
INSERT INTO npr.heartbeat (id) VALUES (1) ON CONFLICT DO NOTHING;
-- Кэш «последнее записанное состояние» — нужен триггеру, чтобы понять,
-- изменилась ли зона относительно ранее сохранённой строки. Одна запись на клиентский PK.
CREATE TABLE IF NOT EXISTS npr.last_seen (
firm_id varchar(12) NOT NULL,
client_code varchar(12) NOT NULL,
limit_kind varchar(5) NOT NULL,
curr_code varchar(12) NOT NULL,
tag varchar(4) NOT NULL,
last_ts timestamptz NOT NULL,
last_zone text NOT NULL,
last_uds numeric(19,2),
PRIMARY KEY (firm_id, client_code, limit_kind, curr_code, tag)
);
-- Зона УДС: CALL / WARN / OK / INF / UNKNOWN. Используется и триггером, и отчётами.
CREATE OR REPLACE FUNCTION npr._zone(fl numeric) RETURNS text AS $$
SELECT CASE
WHEN fl IS NULL THEN 'UNKNOWN'
WHEN fl >= 9.99 THEN 'INF'
WHEN fl <= -9.99 THEN 'CALL'
WHEN fl < 0 THEN 'CALL'
WHEN fl < 1 THEN 'CALL'
WHEN fl < 1.5 THEN 'WARN'
ELSE 'OK'
END;
$$ LANGUAGE sql IMMUTABLE;
-- (д) Триггер-функция: новая запись в history только при смене зоны или раз в 30 мин.
-- Фильтр МД / МД+ / С применяется сразу, чтобы не тратить ресурсы на остальных.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_to_history()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
DECLARE
new_zone text;
last_ts timestamptz;
last_zone text;
should_write boolean := false;
BEGIN
-- Пульс: обновляем не чаще раза в 10 сек, чтобы не делать тысяч UPDATE на один тик экспортёра.
UPDATE npr.heartbeat
SET last_tick = NOW(), tick_count = tick_count + 1
WHERE id = 1 AND last_tick < NOW() - INTERVAL '10 seconds';
IF NEW."MarginType" NOT IN ('МД','МД+','С') THEN
RETURN NEW;
END IF;
new_zone := npr._zone(NEW."FundsLevel");
SELECT ls.last_ts, ls.last_zone
INTO last_ts, last_zone
FROM npr.last_seen ls
WHERE ls.firm_id = NEW."FirmId"
AND ls.client_code = NEW."ClientCode"
AND ls.limit_kind = NEW."LimitKind"
AND ls.curr_code = NEW."CurrCode"
AND ls.tag = NEW."Tag";
IF last_ts IS NULL THEN
should_write := true; -- впервые видим клиента
ELSIF last_zone IS DISTINCT FROM new_zone THEN
should_write := true; -- переход зоны
ELSIF NOW() - last_ts > INTERVAL '30 minutes' THEN
should_write := true; -- heartbeat
END IF;
IF should_write THEN
INSERT INTO npr.history (
firm_id, client_code, limit_kind, curr_code, margin_type,
portfolio_value, init_margin, min_margin, corrected_margin,
rcv1, rcv2, funds_level, status, risk_level
) VALUES (
NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."MarginType",
NEW."PortfolioValue", NEW."InitMargin", NEW."MinMargin", NEW."CorrectedMargin",
NEW."RCV1", NEW."RCV2", NEW."FundsLevel", NEW."Status", NEW."RiskLevel"
);
INSERT INTO npr.last_seen (firm_id, client_code, limit_kind, curr_code, tag,
last_ts, last_zone, last_uds)
VALUES (NEW."FirmId", NEW."ClientCode", NEW."LimitKind", NEW."CurrCode", NEW."Tag",
NOW(), new_zone, NEW."FundsLevel")
ON CONFLICT (firm_id, client_code, limit_kind, curr_code, tag)
DO UPDATE SET last_ts = EXCLUDED.last_ts,
last_zone = EXCLUDED.last_zone,
last_uds = EXCLUDED.last_uds;
END IF;
RETURN NEW;
END;
$BODY$;
-- (е) Триггер на "Portfolio" — AFTER INSERT OR UPDATE.
DROP TRIGGER IF EXISTS portfolio_to_npr_history ON "Portfolio";
CREATE TRIGGER portfolio_to_npr_history
AFTER INSERT OR UPDATE ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_to_history();
-- (е2) Повторный INSERT в "Portfolio" → UPDATE без ошибки.
-- Экспортёр каждый цикл сначала делает подготовленный INSERT по всем строкам
-- и по дубликату ключа переходит к UPDATE. В PostgreSQL 11 каждая ошибка
-- подготовленного INSERT оставляет ~20 КБ в памяти сессии: ~10 тыс. строк/мин
-- дают ≈200 МБ/мин, backend дорастает до OOM и роняет весь кластер (14.09.2026).
-- AFTER UPDATE триггер истории выше срабатывает как обычно.
CREATE OR REPLACE FUNCTION npr.trg_portfolio_insert_as_update()
RETURNS trigger
LANGUAGE plpgsql
AS $BODY$
BEGIN
UPDATE "Portfolio"
SET "MarginType" = NEW."MarginType", "Status" = NEW."Status",
"PortfolioValue" = NEW."PortfolioValue", "InitMargin" = NEW."InitMargin",
"MinMargin" = NEW."MinMargin", "CorrectedMargin" = NEW."CorrectedMargin",
"RCV1" = NEW."RCV1", "RCV2" = NEW."RCV2",
"RiskLevel" = NEW."RiskLevel", "FundsLevel" = NEW."FundsLevel"
WHERE "FirmId" = NEW."FirmId" AND "ClientCode" = NEW."ClientCode"
AND "LimitKind" = NEW."LimitKind" AND "CurrCode" = NEW."CurrCode" AND "Tag" = NEW."Tag";
IF FOUND THEN
RETURN NULL;
END IF;
RETURN NEW;
END;
$BODY$;
DROP TRIGGER IF EXISTS portfolio_insert_as_update ON "Portfolio";
CREATE TRIGGER portfolio_insert_as_update
BEFORE INSERT ON "Portfolio"
FOR EACH ROW
EXECUTE FUNCTION npr.trg_portfolio_insert_as_update();
-- (ж) Прикладная вьюха: УДС с обработкой QUIK-сентинелов и маржинальное требование.
-- QUIK кладёт УДС в диапазон [-9.99, 9.99]: 9.99 = «бесконечность» (капнут сверху),
-- -9.99 = маржин-колл / принудзакрытие (капнут снизу).
CREATE OR REPLACE VIEW npr.v_npr AS
SELECT
ts,
firm_id,
client_code,
margin_type,
portfolio_value,
init_margin,
min_margin,
rcv1,
rcv2,
CASE WHEN funds_level >= 9.99 THEN NULL ELSE funds_level END AS uds,
CASE WHEN funds_level >= 9.99 THEN 'INF'
WHEN funds_level <= -9.99 THEN 'CALL'
ELSE NULL END AS uds_flag,
GREATEST(-rcv1, 0) AS margin_requirement
FROM npr.history;
COMMIT;
-- ===========================================================================
-- Retention-политика: cron-файл /etc/cron.d/npr-retention чистит записи > 2 лет
-- раз в месяц:
-- 0 3 1 * * postgres psql -d quik_export -c \
-- "DELETE FROM npr.history WHERE ts < NOW() - INTERVAL '2 years';"
-- Если нужно партиционирование по месяцам — отдельная миграция.
-- ===========================================================================
-- npr.v_current (текущее состояние по клиентам; снято с рабочей базы 14.09.2026)
CREATE OR REPLACE VIEW npr.v_current AS
SELECT p."FirmId" AS firm_id,
p."ClientCode" AS client_code,
p."LimitKind" AS limit_kind,
p."CurrCode" AS curr_code,
p."Tag" AS tag,
p."MarginType" AS margin_type,
p."RCV1" AS rcv1,
p."RCV2" AS rcv2,
p."FundsLevel" AS funds_level,
p."PortfolioValue" AS portfolio_value,
p."InitMargin" AS init_margin,
p."MinMargin" AS min_margin,
p."CorrectedMargin" AS corrected_margin,
p."Status" AS status,
p."RiskLevel" AS risk_level,
CASE
WHEN p."FundsLevel" >= 9.99 THEN NULL::numeric
ELSE p."FundsLevel"
END AS uds,
CASE
WHEN p."FundsLevel" >= 9.99 THEN 'INF'::text
WHEN p."FundsLevel" <= '-9.99'::numeric THEN 'CALL'::text
ELSE NULL::text
END AS uds_flag,
GREATEST(- p."RCV1", 0::double precision) AS margin_requirement,
npr._zone(p."FundsLevel") AS zone,
hb.last_tick AS ts
FROM "Portfolio" p
CROSS JOIN ( SELECT heartbeat.last_tick
FROM npr.heartbeat
WHERE heartbeat.id = 1) hb
WHERE p."MarginType"::text = ANY (ARRAY['МД'::text, 'МД+'::text, 'С'::text, 'C'::text]);
GRANT SELECT ON npr.v_current TO quik_ro;
@@ -8,7 +8,7 @@
<button data-sec="db"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5.5" rx="7" ry="2.6"/><path d="M5 5.5v13c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6v-13"/><path d="M5 12c0 1.4 3.1 2.6 7 2.6s7-1.2 7-2.6"/></svg></span>База данных<span class="ind {{if .Settings.Postgres.DSN}}ok{{else}}err{{end}}"></span></button>
{{if or .AdvancedUnlocked (index .Modules "quik-mnp")}}<button data-sec="fancy"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h12M12 4l4 4-4 4"/><path d="M20 16H8M12 20l-4-4 4-4"/></svg></span>Обмен Fancy<span class="ind {{if .Settings.Fancy.DepoTablesCheckOK}}ok{{else if .Settings.Fancy.Server}}warn{{end}}"></span></button>
<button data-sec="quik"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20V4M4 20h16"/><rect x="7" y="12" width="2.6" height="5"/><rect x="12" y="8" width="2.6" height="9"/><rect x="17" y="14" width="2.6" height="3"/></svg></span>QUIK МНП<span class="ind {{if .Settings.Quik.Verified}}ok{{else if .Settings.Quik.Server}}warn{{end}}"></span></button>
<button data-sec="quikexport"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l5-5 4 4 7-8"/><path d="M14 8h6v6"/></svg></span>Экспорт QUIK<span class="ind {{if not .QuikExport.Licensed}}err{{else if and .QuikExport.S.Enabled (eq .QuikExport.Service.Active "active")}}ok{{else if .QuikExport.S.Enabled}}warn{{end}}"></span></button>{{end}}
<button data-sec="quikexport"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17l5-5 4 4 7-8"/><path d="M14 8h6v6"/></svg></span>Экспорт QUIK<span class="ind {{.QuikExport.Ind}}"></span></button>{{end}}
<button data-sec="users"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.5"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><circle cx="17" cy="9" r="2.5"/><path d="M15.5 14.5c2.8.3 5.5 2.5 5.5 5.5"/></svg></span>Пользователи<span class="ind {{if .Users.Open}}warn{{else}}ok{{end}}"></span></button>
<button data-sec="crypto"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Криптография<span class="ind {{if .Settings.Crypto.Profile}}ok{{else}}warn{{end}}"></span></button>
<button data-sec="nsd"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18M4 21V10M20 21V10M3 10l9-6 9 6M8 21v-7M12 21v-7M16 21v-7"/></svg></span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
@@ -362,135 +362,7 @@
{{/* ============ ЭКСПОРТ БИРЖЕВОЙ ИНФОРМАЦИИ (QUIK) ============ */}}
<section class="settings-section" id="sec-quikexport">
{{$X := .QuikExport}}{{$S := $X.S}}
<h1>QUIK — Экспорт биржевой информации</h1>
<p class="muted">Модуль ARQA «Экспорт биржевой информации» выгружает данные сервера QUIK (портфели клиентов, позиции, сделки, справочники) в базу PostgreSQL. bj управляет им отсюда: ключи и логин QUIK, где держим базу, какой объём данных выгружаем, состояние службы и место на диске. На этих данных работают модуль «НПР / УДС» и дополнительные проверки M2M. Лицензионный признак <code>quik-export</code>.</p>
{{range $X.Warnings}}<div class="card" style="padding:8px 14px;margin-bottom:8px;background:color-mix(in srgb,var(--warn) 10%,var(--card));display:flex;gap:8px;align-items:center"><span class="dot warn"></span>{{.}}</div>{{end}}
<div class="card">
<h2><span class="dot {{if not $X.Licensed}}err{{else if and $S.Enabled (eq $X.Service.Active "active")}}ok{{else if $S.Enabled}}warn{{else}}err{{end}}"></span>Состояние модуля</h2>
<form method="post" action="/admin/setup/quikexport" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="module">
<label style="display:flex;gap:8px;align-items:center;font-weight:600;margin:0"><input type="checkbox" name="enabled" value="1" {{if $S.Enabled}}checked{{end}} {{if not $X.Licensed}}disabled{{end}}> Модуль подключён</label>
<button type="submit" class="btn" {{if not $X.Licensed}}disabled{{end}}>Сохранить</button>
{{if not $X.Licensed}}<span class="muted">не входит в лицензию — см. раздел «Лицензия»</span>{{end}}
</form>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px">
<div class="nmini" style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Служба quikexport</div>
<strong>{{if not $X.Service.Installed}}не установлена{{else if eq $X.Service.Active "active"}}работает{{else}}{{$X.Service.Active}}{{end}}</strong>
<div class="muted" style="font-size:11.5px">{{if $X.Service.PID}}PID {{$X.Service.PID}} · {{end}}{{if $X.Service.MemoryMB}}память {{$X.Service.MemoryMB}} МБ · {{end}}автозапуск {{if $X.Service.Enabled}}да{{else}}нет{{end}}{{if $X.Service.Restarts}} · перезапусков {{$X.Service.Restarts}}{{end}}</div></div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Данные из QUIK</div>
{{if $X.DB.OK}}<strong>{{if $X.DB.LastTick.IsZero}}нет пульса{{else if lt $X.DB.TickAgeSec 180}}свежие{{else}}устарели{{end}}</strong>
<div class="muted" style="font-size:11.5px">{{if not $X.DB.LastTick.IsZero}}цикл {{msk $X.DB.LastTick}} ({{$X.DB.TickAgeSec}} с назад) · {{end}}клиентов {{$X.DB.Clients}}</div>
{{else}}<strong style="color:var(--err)">база недоступна</strong><div class="muted" style="font-size:11.5px">{{$X.DB.Error}}</div>{{end}}</div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Объём базы {{$S.DBName}}</div>
<strong>{{printf "%.0f" $X.DB.DBSizeMB}} МБ</strong>
<div class="muted" style="font-size:11.5px">история НПР {{$X.DB.HistoryRows}} строк{{if not $X.DB.HistoryFrom.IsZero}} с {{$X.DB.HistoryFrom.Format "02.01.2006"}}{{end}} · рост ≈ {{printf "%.1f" $X.DB.GrowthMBDay}} МБ/сутки ({{$X.DB.Rows24h}} событий за 24 ч)</div></div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Диск {{if $X.DiskDB.OK}}базы данных{{else}}экспортёра{{end}}</div>
{{with $X.DiskDB}}{{if .OK}}<strong style="{{if lt .FreeGB 10.0}}color:var(--err){{end}}">свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}</strong><div class="muted" style="font-size:11.5px">занято {{.UsedPct}}% · {{.Path}}</div>{{end}}{{end}}
{{if not $X.DiskDB.OK}}{{with $X.DiskExp}}<strong>свободно {{printf "%.1f" .FreeGB}} ГБ из {{printf "%.0f" .TotalGB}}</strong><div class="muted" style="font-size:11.5px">занято {{.UsedPct}}%</div>{{end}}{{end}}</div>
<div style="padding:10px;border:1px solid var(--border);border-radius:8px"><div class="muted" style="font-size:11px;text-transform:uppercase">Логи и кэш экспортёра</div>
<strong>{{printf "%.0f" $X.LogsMB}} МБ логов</strong>
<div class="muted" style="font-size:11.5px">{{range $X.Logs}}{{if .Exists}}{{.Name}} {{printf "%.0f" .SizeMB}} МБ · {{end}}{{end}}кэш QAPI {{printf "%.0f" $X.QAPIMB}} МБ · ротация: ежедневно / 200 МБ, 5 копий</div></div>
</div>
{{if $X.DB.Tables}}<details style="margin-top:10px"><summary class="muted" style="cursor:pointer">Крупнейшие таблицы базы</summary>
<table style="font-size:12px;margin-top:6px"><thead><tr><th>Таблица</th><th>Размер</th><th>Строк (оценка)</th></tr></thead><tbody>{{range $X.DB.Tables}}<tr><td><code>{{.Name}}</code></td><td>{{.Size}}</td><td>{{.Rows}}</td></tr>{{end}}</tbody></table></details>{{end}}
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="restart"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>Перезапустить службу</button></form>
<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="{{if eq $X.Service.Active "active"}}stop{{else}}start{{end}}"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>{{if eq $X.Service.Active "active"}}Остановить{{else}}Запустить{{end}}</button></form>
{{if and $X.Service.Installed (not $X.Service.Enabled)}}<form method="post" action="/admin/setup/quikexport/service"><input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="op" value="enable"><button class="btn">Включить автозапуск</button></form>{{end}}
</div>
{{if $X.LogTail}}<details style="margin-top:10px"><summary class="muted" style="cursor:pointer">Последние строки export.log{{if $X.ErrTail}} и export.err{{end}}</summary>
<pre style="font-size:11px;white-space:pre-wrap;max-height:260px;overflow:auto;background:var(--card-2);padding:8px;border-radius:6px">{{range $X.LogTail}}{{.}}
{{end}}{{if $X.ErrTail}}--- export.err ---
{{range $X.ErrTail}}{{.}}
{{end}}{{end}}</pre></details>{{end}}
</div>
<div class="card">
<h2><span class="dot {{if and $S.QuikHost $S.QuikUser $S.KeysNames}}ok{{else}}warn{{end}}"></span>1. Подключение к серверу QUIK</h2>
<p class="muted">Логин экспорта на сервере QUIK брокера (выдаёт администратор QUIK / ARQA) и пара ключей <code>pubring.txk</code> / <code>secring.txk</code> к нему. Одна учётная запись = одна сессия: второй экспортёр с тем же логином получит «Вы уже работаете в системе».</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;grid-template-columns:2fr 1fr;gap:10px;max-width:720px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="quik">
<label>Адрес сервера QUIK <input type="text" name="quik_host" value="{{$S.QuikHost}}" placeholder="91.209.122.88" style="width:100%" required></label>
<label>Порт <input type="number" name="quik_port" value="{{$S.QuikPort}}" style="width:100%"></label>
<label>Пользователь QUIK <input type="text" name="quik_user" value="{{$S.QuikUser}}" style="width:100%" required></label>
<label>Пароль <input type="password" name="quik_password" value="" placeholder="{{if $S.QuikPassword}}(сохранён, оставьте пустым){{else}}пароль{{end}}" style="width:100%"></label>
<label>Имя авторизации (auth-name) <input type="text" name="auth_name" value="{{$S.AuthName}}" placeholder="FontV" style="width:100%"></label>
<div style="align-self:end"><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
<form method="post" action="/admin/setup/quikexport/keys" enctype="multipart/form-data" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;max-width:720px;margin-top:14px;align-items:end">
<input type="hidden" name="section" value="sec-quikexport">
<label>Открытый ключ pubring.txk <input type="file" name="pubring" accept=".txk" style="width:100%"></label>
<label>Закрытый ключ secring.txk <input type="file" name="secring" accept=".txk" style="width:100%"></label>
<button type="submit" class="btn">Загрузить ключи</button>
</form>
<p class="muted" style="font-size:12px;margin-top:6px">Ключи: {{if $S.KeysNames}}{{range $S.KeysNames}}<code>{{.}}</code> {{end}}{{if not $S.KeysAt.IsZero}}(загружены {{msk $S.KeysAt}}){{end}}{{else}}не загружены{{end}}.
В каталоге экспортёра: {{range $X.Files}}{{.Name}} {{if .Exists}}✓ {{.ModTime.Format "02.01 15:04"}}{{else}}✗{{end}} · {{end}}</p>
</div>
<div class="card">
<h2><span class="dot {{if and $S.DBPassword $S.ROPassword}}ok{{else}}warn{{end}}"></span>2. База данных экспорта</h2>
<p class="muted">Куда экспортёр пишет данные. Обычно — наш PostgreSQL на этом же сервере (bj создаст базу, роли и схему одной кнопкой). Внешний сервер — если базу держит заказчик. Логин только для чтения используют модуль «НПР / УДС», проверки M2M и внешние системы (Grafana, отчёты).</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;max-width:720px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="db">
<div style="display:flex;gap:18px;flex-wrap:wrap">
<label style="display:flex;gap:6px;align-items:center;font-weight:600;margin:0"><input type="radio" name="db_mode" value="local" {{if ne $S.DBMode "external"}}checked{{end}} onchange="document.getElementById('qx-ext').style.display='none'"> Наш PostgreSQL (этот сервер)</label>
<label style="display:flex;gap:6px;align-items:center;font-weight:600;margin:0"><input type="radio" name="db_mode" value="external" {{if eq $S.DBMode "external"}}checked{{end}} onchange="document.getElementById('qx-ext').style.display='grid'"> Внешний сервер PostgreSQL</label>
</div>
<div id="qx-ext" style="display:{{if eq $S.DBMode "external"}}grid{{else}}none{{end}};grid-template-columns:2fr 1fr;gap:10px">
<label>Хост <input type="text" name="db_host" value="{{$S.DBHost}}" style="width:100%"></label>
<label>Порт <input type="number" name="db_port" value="{{$S.DBPort}}" style="width:100%"></label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px">
<label>База <input type="text" name="db_name" value="{{$S.DBName}}" style="width:100%"></label>
<label>Пользователь экспорта (запись) <input type="text" name="db_user" value="{{$S.DBUser}}" style="width:100%"></label>
<label>Пароль <input type="password" name="db_password" value="" placeholder="{{if $S.DBPassword}}(сохранён){{else}}задать{{end}}" style="width:100%"></label>
<label>Логин только для чтения <input type="text" name="ro_user" value="{{$S.ROUser}}" style="width:100%"></label>
<label>Пароль <input type="password" name="ro_password" value="" placeholder="{{if $S.ROPassword}}(сохранён){{else}}задать{{end}}" style="width:100%"></label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><button type="submit" class="btn btn-ok">Сохранить</button>
<button type="submit" formaction="/admin/setup/quikexport/dbinit" class="btn" {{if eq $S.DBMode "external"}}disabled{{end}}>Создать базу и роли на этом сервере</button>
<a class="btn" href="/admin/setup/quikexport/sql">SQL для внешнего сервера</a></div>
</form>
<p class="muted" style="font-size:12px;margin-top:8px">Доступ для внешних систем и других модулей: <code>{{if $X.ShowRO}}{{$X.RODSNFull}}{{else}}{{$X.RODSN}}{{end}}</code>
{{if $X.ShowRO}}<a href="/admin/setup#quikexport">скрыть пароль</a>{{else}}<a href="/admin/setup?reveal_ro=1#quikexport">показать пароль</a>{{end}}{{if ne $S.DBMode "external"}} · снаружи: хост <code>{{$X.ServerIP}}</code>, доступ по IP открывает администратор (pg_hba){{end}}.</p>
</div>
<div class="card">
<h2><span class="dot ok"></span>3. Объём данных</h2>
<p class="muted">Какие таблицы QUIK выгружаем. Чем больше набор, тем больше нагрузка на сервер QUIK, база и диск. Портфели пересчитываются каждые «период портфелей» секунд (ARQA рекомендует не менее 60); история изменений НПР/УДС хранится в схеме <code>npr</code>.</p>
<form method="post" action="/admin/setup/quikexport" style="display:grid;gap:10px;margin-top:10px">
<input type="hidden" name="section" value="sec-quikexport"><input type="hidden" name="part" value="volume">
<div style="display:grid;gap:6px">{{range $X.Presets}}
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin:0"><input type="radio" name="preset" value="{{.Key}}" {{if eq $S.Preset .Key}}checked{{end}} onchange="document.getElementById('qx-tables').style.display=this.value==='custom'?'block':'none'"><span><b>{{.Title}}</b><br><span class="muted" style="font-size:12px">{{.Desc}}</span></span></label>{{end}}
</div>
<div id="qx-tables" style="display:{{if eq $S.Preset "custom"}}block{{else}}none{{end}};border:1px solid var(--border);border-radius:8px;padding:10px">
{{range $g := $X.Groups}}<div style="font-weight:600;margin:6px 0 4px">{{$g}}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:4px 12px">{{range $X.Tables}}{{if eq .Group $g}}
<label style="display:flex;gap:6px;align-items:flex-start;font-weight:400;margin:0;font-size:12.5px"><input type="checkbox" name="tables" value="{{.Name}}" {{if index $X.Enabled .Name}}checked{{end}}><span><code>{{.Name}}</code> — {{.Title}} <span class="muted">· объём: {{.Load}}{{if .Note}} · {{.Note}}{{end}}</span></span></label>{{end}}{{end}}</div>{{end}}
</div>
<details><summary class="muted" style="cursor:pointer">Сейчас включено: {{len $S.Tables}} таблиц — {{range $S.Tables}}<code>{{.}}</code> {{end}}</summary></details>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:10px;max-width:820px">
<label>Период портфелей, с <input type="number" name="portfolio_period" min="10" value="{{$S.PortfolioPeriod}}" style="width:100%"></label>
<label>Период «Текущих торгов», с <input type="number" name="param_period" min="0" value="{{$S.ParamPeriod}}" style="width:100%"></label>
<label>Виды лимитов (LimitKinds) <input type="text" name="limit_kinds" value="{{$S.LimitKinds}}" style="width:100%"></label>
<label>Уровень лога (DebugLevel) <input type="number" name="debug_level" min="0" max="255" value="{{$S.DebugLevel}}" style="width:100%"></label>
</div>
<div><button type="submit" class="btn btn-ok">Сохранить</button></div>
</form>
</div>
<div class="card">
<h2><span class="dot {{if not $S.AppliedAt.IsZero}}ok{{else}}warn{{end}}"></span>4. Применить к экспортёру</h2>
<p class="muted">bj собирает <code>export.ini</code>, <code>info.ini</code>, секцию ODBC и ключи из настроек выше и устанавливает их в каталог службы (<code>{{$S.Dir}}</code>) с резервной копией прежних файлов, настраивает ротацию логов и перезапускает экспортёр. {{if not $S.AppliedAt.IsZero}}Последнее применение: {{msk $S.AppliedAt}}.{{end}}{{if not $S.Imported}} Если экспортёр уже настроен вручную — сначала «Прочитать настройки из экспортёра».{{end}}</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
<a class="btn" href="/admin/setup/quikexport/preview" target="_blank">Предпросмотр файлов</a>
<form method="post" action="/admin/setup/quikexport/apply" onsubmit="return confirm('Установить настройки в экспортёр и перезапустить службу? Выгрузка прервётся на несколько секунд.')"><input type="hidden" name="section" value="sec-quikexport"><button class="btn btn-ok" {{if not $X.Licensed}}disabled{{end}}>Применить настройки и перезапустить экспортёр</button></form>
<form method="post" action="/admin/setup/quikexport/import"><input type="hidden" name="section" value="sec-quikexport"><button class="btn" {{if not $X.Service.Installed}}disabled{{end}}>Прочитать настройки из экспортёра</button></form>
</div>
<p class="muted" style="font-size:12px;margin-top:10px">Установка на новом сервере: дистрибутив ARQA (install_quikexport-12.x.run, Linux) ставится установщиком bj в <code>{{$S.Dir}}</code>; нужны пакеты unixodbc, odbc-postgresql, libjemalloc2. Обновления экспортёра приходят через канал обновлений bj после проверки у нас.</p>
</div>
{{.QuikExport.HTML}}
</section>
{{/* ============ КРИПТОГРАФИЯ ============ */}}
@@ -923,16 +795,16 @@
<div class="card">
<h2>Компоненты</h2>
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся.</p>
<p class="muted">Каждый компонент обновляется отдельно: своя версия в релизе, перезапускается только его служба. Файлы ставит root-установщик после повторной проверки подписи и контрольных сумм. Модули, которых нет на этом сервере, обновлением не ставятся — кроме выделенных из ядра (НПР / УДС, экспорт QUIK): если модуль входит в лицензию, он ставится вместе с обновлением, иначе его раздел перестал бы работать.</p>
<div style="overflow-x:auto"><table style="margin-top:8px">
<thead><tr><th>Компонент</th><th>Установлена</th><th>Доступна</th><th>Служба</th><th></th></tr></thead>
<tbody>
{{range .Components}}<tr>
<td><strong>{{.Title}}</strong><div class="muted" style="font-size:11px"><code>{{.Name}}</code></div></td>
<td>{{if .Present}}{{if .Installed}}{{.Installed}}{{else}}<span class="muted">неизвестна</span>{{end}}{{else}}<span class="muted">не установлен</span>{{end}}</td>
<td>{{if .Available}}{{.Available}}{{if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
<td>{{if .Available}}{{.Available}}{{if .New}} <span class="hero-status warn">к установке</span>{{else if .Update}} <span class="hero-status warn">новее</span>{{end}}{{else}}<span class="muted">нет данных</span>{{end}}</td>
<td><code style="font-size:11px">{{.Unit}}</code></td>
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('Обновить компонент до {{.Available}}? Его служба перезапустится.');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">Обновить</button></form>{{end}}</td>
<td>{{if .Update}}<form method="post" action="/admin/setup/update/apply" style="margin:0" onsubmit="return confirm('{{if .New}}Установить модуль {{.Available}}? Будет включена его служба.{{else}}Обновить компонент до {{.Available}}? Его служба перезапустится.{{end}}');"><input type="hidden" name="section" value="sec-update"><input type="hidden" name="component" value="{{.Name}}"><button type="submit" class="btn btn-ok">{{if .New}}Установить{{else}}Обновить{{end}}</button></form>{{end}}</td>
</tr>{{end}}
</tbody>
</table></div>