diff --git a/internal/lkgateway/quikexport_proxy.go b/internal/lkgateway/quikexport_proxy.go index ee514fc..5b78f43 100644 --- a/internal/lkgateway/quikexport_proxy.go +++ b/internal/lkgateway/quikexport_proxy.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "strings" + "sync" "time" ) @@ -59,14 +60,33 @@ func quikExportDo(ctx context.Context, rc *RuntimeConfig, method, uri string, bo return quikExportHTTP.Do(req) } +// qxSection — последний ответ службы: страница «Настройка» не ждёт её дольше qxSectionWait, а отдаёт +// последний удачный раздел (служба могла задуматься над статистикой базы). +var qxSection struct { + sync.Mutex + v QuikExportView + at time.Time + ro bool +} + +const qxSectionWait = 3 * time.Second + func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView { uri := "/section" if showRO { uri += "?reveal_ro=1" } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), qxSectionWait) defer cancel() resp, err := quikExportDo(ctx, rc, http.MethodGet, uri, nil, "") + if err != nil { + qxSection.Lock() + v, at, ro := qxSection.v, qxSection.at, qxSection.ro + qxSection.Unlock() + if !at.IsZero() && ro == showRO && time.Since(at) < 30*time.Minute { + return v + } + } if err == nil { b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) resp.Body.Close() @@ -76,7 +96,11 @@ func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView { 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")} + v := QuikExportView{HTML: template.HTML(b), Ind: resp.Header.Get("X-BJ-Indicator")} + qxSection.Lock() + qxSection.v, qxSection.at, qxSection.ro = v, time.Now(), showRO + qxSection.Unlock() + return v } } return QuikExportView{Ind: "err", HTML: template.HTML(`

QUIK — Экспорт биржевой информации

`) + moduleMessage( diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 31a32c9..bfaf761 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -48,6 +48,12 @@ type adminTemplates struct { // registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux. func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) { h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc} + go func() { // прогрев: версии компонентов, пользователи ЛК, раздел QUIK-экспорта + time.Sleep(5 * time.Second) + systemVersions(rc) + lkUsersForSetup(rc) + quikExportView(rc, false) + }() mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method", http.StatusMethodNotAllowed) diff --git a/internal/lkgateway/web/templates/part_fancy.html b/internal/lkgateway/web/templates/part_fancy.html index 8263016..4b484dd 100644 --- a/internal/lkgateway/web/templates/part_fancy.html +++ b/internal/lkgateway/web/templates/part_fancy.html @@ -189,11 +189,15 @@ try{ tried=+sessionStorage.getItem('bj-upd-auto')||0; }catch(e){} if(!force && (now-last<600 || now-tried<600)) return; // проверяли меньше 10 минут назад (или уже пробовали сами) try{ sessionStorage.setItem('bj-upd-auto',String(Math.floor(now))); }catch(e){} - busy=true; var b=box.querySelector('.stc'); if(b){ b.className='stc stc-run'; var t=b.querySelector('b'); if(t) t.textContent='Проверяю обновления…'; } + busy=true; var b=box.querySelector('.stc'), t=b&&b.querySelector('b'), was=b&&[b.className,t?t.textContent:'']; + if(b){ b.className='stc stc-run'; if(t) t.textContent='Проверяю обновления…'; } + function restore(){ busy=false; if(b){ b.className=was[0]; if(t) t.textContent=was[1]; } } fetch('/admin/setup/update/check',{method:'POST',headers:{'X-BJ-Ajax':'1'},cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ box.dataset.last=String(Math.floor(Date.now()/1000)); + // ничего нового — страницу не перерисовываем (полный рендер «Настройки» дорогой) + if(d && d.ok && (d.has_update?'1':'0')===box.dataset.has && (!d.has_update || box.textContent.indexOf(d.available||'\u0000')>=0)){ restore(); return; } location.reload(); // состояние компонентов — с сервера - }).catch(function(){ busy=false; location.reload(); }); + }).catch(function(){ restore(); }); } box.addEventListener('click',function(e){ if(e.target.closest('[data-upd-check]')) check(true); }); document.querySelectorAll('.settings-nav [data-sec="update"]').forEach(function(b){ b.addEventListener('click',function(){ setTimeout(function(){ check(false); },0); }); }); diff --git a/modules/npr/npr.go b/modules/npr/npr.go index 42ac68b..a71ebc5 100644 --- a/modules/npr/npr.go +++ b/modules/npr/npr.go @@ -454,7 +454,18 @@ func (s *Server) Handler() http.Handler { _ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks) var lastTS *time.Time var total, hour, day int64 - _ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day) + // По первичному ключу, без полного прохода npr.history (десятки миллионов строк, индекса по ts нет): + // последнее событие — max(id), всего — оценка планировщика, за час/сутки — двоичный поиск по id. + var minID, maxID *int64 + _ = p.QueryRow(ctx, `SELECT (SELECT ts FROM npr.history ORDER BY id DESC LIMIT 1), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), (SELECT min(id) FROM npr.history), (SELECT max(id) FROM npr.history)`).Scan(&lastTS, &total, &minID, &maxID) + if minID != nil && maxID != nil { + now := time.Now() + hour = *maxID - nprHistIDAt(ctx, p, now.Add(-time.Hour), *minID, *maxID+1) + 1 + day = *maxID - nprHistIDAt(ctx, p, now.Add(-24*time.Hour), *minID, *maxID+1) + 1 + if total < day { + total = day + } + } out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)} if lastTick != nil { out["last_tick"] = lastTick.Format(time.RFC3339) @@ -582,3 +593,22 @@ func (s *Server) Handler() http.Handler { }) return modkit.RequireKey(s.cfg.KeyPath, mux) } + +// nprHistIDAt — первый id в npr.history со временем не раньше t. История только дописывается (id растёт +// вместе с ts), поэтому двоичный поиск по первичному ключу: ~25 коротких запросов вместо полного прохода +// таблицы (индекса по ts нет). lo, hi — min(id) и max(id). +func nprHistIDAt(ctx context.Context, p *pgxpool.Pool, t time.Time, lo, hi int64) int64 { + for lo < hi { + mid := lo + (hi-lo)/2 + var ts time.Time + if err := p.QueryRow(ctx, `SELECT ts FROM npr.history WHERE id >= $1 ORDER BY id LIMIT 1`, mid).Scan(&ts); err != nil { + return hi + } + if ts.Before(t) { + lo = mid + 1 + } else { + hi = mid + } + } + return lo +} diff --git a/modules/quikexport/quikexport.go b/modules/quikexport/quikexport.go index 76ff330..4911bf4 100644 --- a/modules/quikexport/quikexport.go +++ b/modules/quikexport/quikexport.go @@ -920,6 +920,25 @@ func quikExportPool(s QuikExportSettings) (*pgxpool.Pool, error) { return p, nil } +// nprHistIDAt — первый id в npr.history со временем не раньше t. История только дописывается (id растёт +// вместе с ts), поэтому двоичный поиск по первичному ключу: ~25 коротких запросов вместо полного прохода +// таблицы (индекса по ts нет). lo, hi — min(id) и max(id). +func nprHistIDAt(ctx context.Context, p *pgxpool.Pool, t time.Time, lo, hi int64) int64 { + for lo < hi { + mid := lo + (hi-lo)/2 + var ts time.Time + if err := p.QueryRow(ctx, `SELECT ts FROM npr.history WHERE id >= $1 ORDER BY id LIMIT 1`, mid).Scan(&ts); err != nil { + return hi + } + if ts.Before(t) { + lo = mid + 1 + } else { + hi = mid + } + } + return lo +} + func quikExportStats(s QuikExportSettings) quikExportDBStats { st := quikExportDBStats{ExporterUser: s.DBUser} if s.ROPassword == "" { @@ -954,10 +973,18 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats { } var histBytes int64 var from *time.Time - if err := p.QueryRow(ctx, `SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), pg_total_relation_size('npr.history'), (SELECT min(ts) FROM npr.history), (SELECT count(*) FROM npr.history WHERE ts >= now() - interval '24 hours')`).Scan(&st.HistoryRows, &histBytes, &from, &st.Rows24h); err == nil { + var minID, maxID *int64 + // Начало истории и строки за сутки — по первичному ключу (см. nprHistIDAt), без прохода по таблице. + if err := p.QueryRow(ctx, `SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), pg_total_relation_size('npr.history'), (SELECT ts FROM npr.history ORDER BY id LIMIT 1), (SELECT min(id) FROM npr.history), (SELECT max(id) FROM npr.history)`).Scan(&st.HistoryRows, &histBytes, &from, &minID, &maxID); err == nil { if from != nil { st.HistoryFrom = *from } + if minID != nil && maxID != nil { + st.Rows24h = *maxID - nprHistIDAt(ctx, p, time.Now().Add(-24*time.Hour), *minID, *maxID+1) + 1 + if st.Rows24h < 0 { + st.Rows24h = 0 + } + } if st.HistoryRows > 0 { st.GrowthMBDay = float64(histBytes) / float64(st.HistoryRows) * float64(st.Rows24h) / (1 << 20) }