Пульт администратора
данные не загружены
HEALTH
нет данных Запустите обновление или прогон калибровки
API: не проверен

Воронка пайплайна

Донор → фильтр → очередь → канал → база объявлений → лид. Узкое место подсвечено красным.

Поток постов по часам

Столбики — факт, пунктир — ожидание по модели Хольта-Уинтерса (сезонный профиль суток).

Органика и реклама

Норма — не более 1 промо на 30 органических (OS ≥ 0.95).

Каналы: постов за окно

Сравнение наполнения каналов. Норматив — channel_targets в pult_models.json (300/сут по умолчанию, 600 крупным, 200 узким).

Тепловая карта активности

Сутки (24 часа) × каналы. Чем ярче, тем больше постов в этот час.
нет мало средне пик

Что требует внимания

Автоматические выводы аудита и пороги из pult_models.json.

Каналы публикации

Статус, маршруты, наполнение и управление. Кнопки «Пауза»/«Возобновить» переключают все маршруты канала.

Боты

Роль, токен, права в каналах. Проверка идёт через эндпойнты пульта GET /api/v1/parsic/pult-bots-health и GET /api/v1/parsic/pult-postic-metrics — тот же пароль пульта, отдельная роль администратора не нужна.

Права ботов в каналах

Строки из pult-bots-health: тип чата, статус бота и право публикации.
КаналБот-публикаторСтатусМожет публиковатьОшибка

Очередь Postic

Данные из pult-postic-metrics (Redis db3): очередь, DLQ, ключи идемпотентности.

Доноры

Здоровье донора DH считается по формуле M11: 0.40·e^(−Δt/48ч) + 0.35·min(1, λ/6.48) + 0.25·(1−dr).
ДонорКанал-приёмникВкл Постов за окноПрошло фильтр DHПоследняя ошибкаДействия

Доноры ↔ Каналы

Группировка по каналам-приёмникам: публикации по классам за 7 дней (Redis soyuz:stats:pub) и тумблер маршрута. Выключение подхватывается parsic без рестарта.

Маршруты relay

Каждая строка — связка «донор → канал». Правка сохраняется в channel_relay.json через API пульта.
ДонорКаналТема ВклРегион min_hits includeexcludeДействия

Массовая правка фильтров

Правка применяется сразу ко всем маршрутам канала или донора (POST /api/v1/parsic/relay-entity-filters-patch).

По каналам

Меняет include/exclude/min_hits у всех маршрутов канала.

По донорам

Меняет фильтры всех маршрутов одного донора.

Прогон калибровки

Последовательно опрашивает бэкенд, очередь, ботов и базу объявлений, затем пересчитывает Health Score по формулам pult_models.json.
Health Score до
после прогона

Чек-лист проверок

Клик по строке ведёт в нужный раздел пульта или во внешний источник.

Динамика Health Score

История прогонов хранится в браузере оператора (localStorage), последние 60 записей.

Текст отчёта

То, что попадёт в выгрузку.

Модель прогноза

Сводка. Прогноз по каждому показателю виден прямо на его карточке в разделе «Обзор»; здесь — выбор модели и общий горизонт. Модели и параметры — из pult_models.json (раздел models).

Факт и прогноз потока постов

Сплошная — факт по суткам, пунктир — прогноз, заливка — доверительный интервал 95 %.

Прогноз по показателям на 7 дней

Те же значения, что подписаны под карточками показателей. Источник указан в каждой строке.

Сравнение моделей

Балл = 0.35·точность + 0.25·устойчивость + 0.20·интерпретируемость + 0.20·стоимость.

Добавить донора

Источник попадёт в channel_relay.json, Parsic подхватит на следующем цикле.

Фильтры союзов

Пресеты из union_filter_presets.json, правки сохраняются в union_filter_overrides.json.

Партнёры · подключение по API

Все партнёры со страницей на союз.online могут загружать предложения и получать запросы союза по ключу. Ключ показывается один раз, в базе — только хэш. GET/POST /api/v1/parsic/partnery.

Parsic

Период опроса донора. Правило: не ниже (уникальных доноров ÷ 40) × 60 с — иначе FloodWait; не выше 900 с. Значение из JSON перебивает переменные окружения.

Пульт

История серверных снимков

GET /api/v1/parsic/pult-reports — снимки аудита, сохранённые бэкендом.
ВремяОкноПостов доноровПостов в каналах РазрывМаршрутов вкл.

Отчёты владельца · что сделано

Записи журнала проверок за период, выкатки и цифры. Обновляется само при входе; «С прошлого входа» считает от предыдущего открытия пульта; тумблеры «Ежедневные»/«Еженедельные» открывают архив отчётов, которые сервер складывает сам. GET /api/v1/parsic/otchet-vladeltsa.

Провайдеры ИИ · балансы и пополнение

Кто отвечает в Доме Советов, живая проверка ключей, остатки средств и ссылки на пополнение. Порядок задаёт LLM_PORYADOK в .env (сейчас OpenAI → Anthropic → OpenRouter). GET /api/v1/parsic/provaydery.

Агенты союзов

Роли-агенты каждого союза из /data/agenty_soyuzov.json: 5 главных + 7 смежных, на зеркалах — кнопки «Дом Советов» и «Агенты Komanda.ai».

СОЮЗ ИНФОРМ · обработчик потока и контролёры

«СОЮЗ ИНФОРМ» — обработчик и классификатор всей информации над Парсиком (сбор) и Постиком (публикация): словари отраслей, таксономия, маршруты в каналы и ленты, база знаний Дома Советов. «СоюзИнформ» — медиасеть (каналы и ленты). Контролёры каждые 10 минут проверяют ленты, жанры, очереди, пульс сбора, базу, обучение и провайдеров; чинят, что можно чинить безопасно; тревоги — владельцу в Telegram. /data/inform_kontrol.json

Дом Советов · состояние

База знаний, память успешных сценариев, диалоги и расход по провайдерам. GET /api/v1/dom/sostoyanie, /scenarii.

Дорожная карта запуска · этапы, юрформа, тарифы

Текущий этап и следующий шаг считает сервер по маркерам системы (режим оплаты, тарифы, согласия, оплаты, реквизиты админов) и отметкам владельца. Кнопка «Включить тарифы» запускает пробный период. GET /api/v1/parsic/roadmap.

Документы проекта

Файлы ищутся рядом с пультом на том же nginx. Наличие проверяется запросом HEAD: доступные — кликабельные ссылки, отсутствующие помечены серым.
проверяю наличие файлов…

Выгрузка текущего состояния

Формируется в браузере из того, что пульт уже загрузил: метрики, проверки, ошибки запросов.
Что внутри выгрузки
  • Health Score и вклад каждого показателя в него.
  • Все метрики со значением и зоной (зелёная / жёлтая / красная).
  • Результаты последнего прогона калибровки и что делать по каждому провалу.
  • Список запросов к API, которые не прошли — с текстом ошибки.

Лендинги проекта

Страницы союзов, доноров и партнёров на нашем домене (GET /api/v1/parsic/landings). Из этого же списка берётся активный чип «Лендинг» рядом с донором, каналом, союзом и маршрутом.
загружаю список лендингов…
Пульт СОЮЗ · формулы и пороги — ui/data/pult_models.json · аудит — docs/AUDIT_BOTY_KANALY_20260814.md · все документы
'; download('soyuz_pult_report_'+Date.now()+'.html', html, 'text/html;charset=utf-8'); } /* ============================================================ 11. ПРОГНОЗ ============================================================ */ function dailyFact(){ /* Факт публикаций по суткам UTC (Redis soyuz:stats:pub) в охвате пульта; сегодняшние неполные сутки не берём. Пусто — Redis недоступен. */ var dp = S.dailyPub; if(!dp || !dp.days || !dp.days.length) return []; var ids = scopedChannels().filter(function(c){ return !c.mirror && !c.inform; }).map(function(c){ return c.id.toLowerCase(); }); var out = []; dp.days.forEach(function(d){ if(d.partial) return; var sum = 0; Object.keys(d.channels||{}).forEach(function(ch){ if(ids.indexOf(String(ch).toLowerCase())>=0) sum += (+d.channels[ch]||0); }); out.push({date:d.date, n:sum}); }); return out; } function forecastFor(key){ var b = MODELS.baseline||FALLBACK_MODELS.baseline; /* λ — из факта публикаций за полные сутки; до 05.09 бралась июльская константа 900.67 при 2 500–3 000 публикаций в сутки по факту. */ var fact = dailyFact().slice(-7); var lam = b.lambda_day||900.6667, lamSrc = 'базовая строка моделей (июль), факта по суткам нет'; if(fact.length>=3){ lam = fact.reduce(function(s,x){ return s+x.n; },0)/fact.length; lamSrc = 'среднее за '+fact.length+' полных суток по факту публикаций'; } var factMax = fact.length ? Math.max.apply(null, fact.map(function(x){ return x.n; })) : 0; var mdl = (MODELS.models||[]).filter(function(m){return m.key===key;})[0] || {params:{}}; var p = mdl.params||{}; var res = {}; function poisson(h){ var N=h*lam, phi=(p.phi||6), z=(p.z||1.96), s=z*Math.sqrt(phi*N); return {n:N, lo:Math.max(0,N-s), hi:N+s}; } function holt(h){ var L = p.L_final||1021.097, T = p.T_final||49.05, phi = p.phi||0.85, cap = p.cap_per_day||1668; if(fact.length>=3){ L = fact[fact.length-1].n; T = (fact[fact.length-1].n - fact[0].n)/Math.max(1,fact.length-1); cap = Math.max(cap, Math.round(factMax*1.3)); } var sum = 0; for(var i=1;i<=h;i++){ var acc=0; for(var j=1;j<=i;j++) acc += Math.pow(phi,j); sum += Math.min(cap, L + T*acc); } return {n:sum, lo:sum*0.88, hi:sum*1.12}; } function logistic(h){ var K=p.K||1668, N0=p.N0||lam, r=p.r||0.05, s=0; if(fact.length>=3){ K = Math.max(K, Math.round(factMax*1.3)); N0 = lam; } for(var i=1;i<=h;i++) s += K/(1+((K-N0)/N0)*Math.exp(-r*i)); return {n:s, lo:s*0.9, hi:s*1.1}; } function blend(h){ var a=poisson(h), c=holt(h); return {n:0.6*a.n+0.4*c.n, lo:Math.min(a.lo,c.lo), hi:Math.max(a.hi,c.hi)}; } var fn = key==='poisson'?poisson : key==='holt'?holt : key==='logistic'?logistic : key==='holt_winters'?poisson : blend; res.d7 = fn(7); res.d30 = fn(30); /* Лиды — Beta-Binomial: E[p]=a/(a+b), лид = CTR × CR × показы × уникальность */ var bb = (MODELS.models||[]).filter(function(m){return m.key==='beta_binomial';})[0] || FALLBACK_MODELS.models[5]; var bp = bb.params||{}; var ctr = (bp.ctr_prior_a||8)/((bp.ctr_prior_a||8)+(bp.ctr_prior_b||992)); var cr = (bp.cr_prior_a||15)/((bp.cr_prior_a||15)+(bp.cr_prior_b||85)); var fe = (MODELS.forecast_example||FALLBACK_MODELS.forecast_example).params||{}; var views = fe.views_per_post||120, u = fe.unique_factor||0.35; res.leads7 = Math.round(res.d7.n*views*ctr*cr*u); res.leads30 = Math.round(res.d30.n*views*ctr*cr*u); res.model = mdl; res.ctr = ctr; res.cr = cr; res.lam = lam; res.lamSrc = lamSrc; res.fact = fact; return res; } function renderForecast(){ var sel = $('f-model'); if(!sel.options.length){ var list = (MODELS.models||FALLBACK_MODELS.models).slice().sort(function(a,b){ return (b.score||0)-(a.score||0); }); sel.innerHTML = list.map(function(m){ return ''; }).join(''); var pref = Store.get('model','blend'); if($$('#f-model option').some(function(o){return o.value===pref;})) sel.value=pref; } var key = sel.value || 'poisson'; Store.set('model', key); var f = forecastFor(key); $('model-note').textContent = (f.model.use||'')+ (f.model.formula?(' Формула: '+f.model.formula):'')+ ' · λ = '+num(Math.round(f.lam))+' постов/сут — '+f.lamSrc+'.'; $('analytics-kpi').innerHTML = [ ['Постов за 7 дней', num(f.d7.n), 'ДИ 95 %: '+num(f.d7.lo)+' … '+num(f.d7.hi)], ['Постов за 30 дней', num(f.d30.n), 'ДИ 95 %: '+num(f.d30.lo)+' … '+num(f.d30.hi)], ['Лидов за 7 дней', num(f.leads7), 'CTR '+pct(f.ctr,2)+' · CR '+pct(f.cr,1)], ['Лидов за 30 дней', num(f.leads30), 'уникальность 0.35'] ].map(function(r){ return '
'+esc(r[0])+'
'+ '
'+r[1]+'
'+ '
'+esc(r[2])+'
'; }).join(''); /* График: факт по суткам (Redis stats:pub, охват пульта) + 14 суток прогноза. До 05.09 «факт» был зашитым массивом [780, 830, …] — реконструкцией по λ. */ var factDays = dailyFact(); var labels = [], fact = [], fc = [], lo = [], hi = []; factDays.forEach(function(d){ labels.push(d.date.slice(5)); fact.push(d.n); fc.push(null); lo.push(null); hi.push(null); }); if(!factDays.length){ labels.push('факт недоступен'); fact.push(null); fc.push(null); lo.push(null); hi.push(null); } for(var d=1; d<=14; d++){ var n = (d<=7) ? f.d7.n/7 : f.d30.n/30; // среднесуточный темп из выбранной модели labels.push('+'+d+' сут'); fact.push(null); fc.push(Math.round(n)); lo.push(Math.round(n*0.85)); hi.push(Math.round(n*1.18)); } mkChart('forecast', {type:'line', data:{labels:labels, datasets:[ {label:(factDays.length?'Факт: публикации за сутки':'Факт недоступен'), data:fact, borderColor:cssv('--accent'), backgroundColor:'rgba(56,189,248,.18)', fill:false, tension:.25, pointRadius:3}, {label:'Прогноз', data:fc, borderColor:cssv('--gold'), borderDash:[6,4], fill:false, tension:.25, pointRadius:0}, {label:'Верхняя граница', data:hi, borderColor:'transparent', backgroundColor:'rgba(240,180,41,.12)', fill:'+1', pointRadius:0}, {label:'Нижняя граница', data:lo, borderColor:'transparent', backgroundColor:'rgba(240,180,41,.12)', fill:false, pointRadius:0} ]}, options:{responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom'}}, scales:{y:{beginAtZero:true}}}}); renderForecastSummary(); var models = (MODELS.models||FALLBACK_MODELS.models).slice().sort(function(a,b){return (b.score||0)-(a.score||0);}); var mx = 5; $('analytics-bars').innerHTML = models.map(function(m){ return '
'+esc(m.name)+ (m.winner?' рабочая':'')+'
'+ '
'+ '
'+(m.score||'—')+'
'; }).join(''); } /* Сводка прогнозов — те же числа, что подписаны под карточками показателей. */ var FC_ROWS = [ {key:'POSTS', label:'Посты за сутки', fmt:function(v){ return num(v); }}, {key:'LEAD_ABS', label:'Лиды за сутки', fmt:function(v){ return num(v); }}, {key:'OS', label:'Доля органики', fmt:function(v){ return pct(v); }}, {key:'DLQ', label:'Очередь потерь DLQ', fmt:function(v){ return num(v); }}, {key:'TLAG_P90', label:'Задержка донор→канал, p90', fmt:function(v){ return fmtAge(v); }}, {key:'CFR', label:'Заполняемость каналов', fmt:function(v){ return pct(v); }}, {key:'IY', label:'Ingest Yield', fmt:function(v){ return pct(v); }}, {key:'PSR', label:'Publish Success', fmt:function(v){ return pct(v,2); }}, {key:'COV', label:'Активные доноры', fmt:function(v){ return pct(v); }}, {key:'LIVE', label:'Живость каналов', fmt:function(v){ return pct(v); }} ]; function renderForecastSummary(){ var box = $('forecast-summary'); if(!box) return; box.innerHTML = FC_ROWS.map(function(r){ var f = metricForecast(r.key, 7); return '
'+ ''+esc(r.label)+''+ (f ? (''+esc(r.fmt(f.v))+' · диапазон '+esc(r.fmt(Math.min(f.lo,f.hi)))+' – '+ esc(r.fmt(Math.max(f.lo,f.hi)))+ ' ('+esc(f.src)+')') : 'нет данных — нужно минимум 3 замера пульта')+ '
'; }).join(''); } /* ============================================================ 11a. ДОКУМЕНТЫ И ОТЧЁТЫ Файлы лежат рядом на nginx; наличие проверяем запросом HEAD. ============================================================ */ var DOC_FILES = [ {file:'ЖУРНАЛ_ПРОВЕРОК_СОЮЗ.md', name:'Журнал проверок СОЮЗа', note:'Что проверялось, какие регрессии найдены, реестр «не делать».'}, {file:'ПРОМТ_РЕВИЗИЯ_И_АУДИТ.md', name:'Промт независимой ревизии и аудита', note:'Полная ревизия: данные, пульты, боты, сайты, монетизация.'}, {file:'ПРОМТ_ЛЕНТЫ_СОЮЗИНФОРМ_И_ПУЛ_ПУБЛИКАТОРОВ.md', name:'Ленты СоюзИнформ и пул публикаторов', note:'Словари лент по отраслям и масштабирование публикации.'}, {file:'РЕГЛАМЕНТ_ПОИСКА_ДОНОРОВ.md', name:'Регламент поиска доноров', note:'Как ищутся и оцениваются новые источники.'}, {file:'AUDIT_BOTY_KANALY_20260814.md', name:'Аудит ботов и каналов (14.08)', note:'Кто чем публикует, где нет прав, какие каналы молчат.'}, {file:'FORMULY_I_MODELI_ROSTA_20260814.md', name:'Формулы и модели роста', note:'Как считается каждый показатель и Health Score.'}, {file:'TG_CHANNELS_BOTS_PLAN_CHECKLIST.md', name:'План и чек-лист по каналам и ботам', note:'Рабочий канон: маршруты, фильтры, формат публикаций.'}, {file:'DEPLOY_SUCCESS_METHOD.md', name:'Метод выкатки', note:'Порядок деплоя и проверки после него.'} ]; function docCandidates(file){ // Единственный путь — API пульта с паролем (04.09). return ['/api/v1/parsic/doc?name='+encodeURIComponent(file)]; } /** HEAD-проверка с таймаутом; возвращает рабочий URL либо null. */ function probeUrl(url, ms){ var ctl = (typeof AbortController!=='undefined') ? new AbortController() : null; var timer = setTimeout(function(){ if(ctl) ctl.abort(); }, ms||6000); /* Документы идут через API пульта — ему нужен пароль в заголовке; HEAD там не разрешён, поэтому проверяем обычным GET (04.09). */ var api = url.indexOf('/api/')===0; var opt = {method: api?'GET':'HEAD', cache:'no-store', signal: ctl?ctl.signal:undefined}; if(api) opt.headers = {'X-Pult-Auth': authToken()}; return fetch(url, opt) .then(function(r){ clearTimeout(timer); return r.ok ? url : null; }) .catch(function(){ clearTimeout(timer); return null; }); } /** Открыть документ: запрос с паролем, показ из памяти браузера. */ function openDoc(url, name, skachat){ fetch(url, {headers:{'X-Pult-Auth': authToken()}, cache:'no-store'}) .then(function(r){ if(!r.ok) throw new Error('HTTP '+r.status); return r.blob(); }) .then(function(b){ var u = URL.createObjectURL(b); if(skachat){ var a = document.createElement('a'); a.href = u; a.download = name; a.click(); } else { window.open(u, '_blank', 'noopener'); } setTimeout(function(){ URL.revokeObjectURL(u); }, 60000); }) .catch(function(e){ alert('Документ не открылся: '+e.message); }); } function findDoc(file){ var list = docCandidates(file), i = 0; function next(){ if(i>=list.length) return Promise.resolve(null); return probeUrl(list[i++]).then(function(u){ return u || next(); }); } return next(); } var docsBusy = false; function renderDocs(force){ var box = $('docs-list'); if(!box) return; if(docsBusy) return; if(box.dataset.done==='1' && !force) return; docsBusy = true; box.innerHTML = '
проверяю наличие файлов…
'; var results = new Array(DOC_FILES.length); DOC_FILES.reduce(function(chain, d, i){ return chain.then(function(){ return findDoc(d.file).then(function(url){ results[i] = url; }); }); }, Promise.resolve()).then(function(){ box.innerHTML = DOC_FILES.map(function(d, i){ var url = results[i]; return '
'+ '
'+(url ? (''+esc(d.name)+'') : esc(d.name))+'
'+ '
'+esc(d.note)+' · '+esc(d.file)+'
'+ (url ? ('') : 'файл не выгружен')+ '
'; }).join(''); box.dataset.done = '1'; box.querySelectorAll('[data-doc]').forEach(function(el){ el.addEventListener('click', function(ev){ ev.preventDefault(); openDoc(el.dataset.doc, el.dataset.name, el.dataset.dl==='1'); }); }); docsBusy = false; }); } /* ============================================================ 12. НАВИГАЦИЯ, ТЕМА, СОБЫТИЯ ============================================================ */ /** Высота липкой шапки → в CSS-переменную, чтобы вкладки и якоря не перекрывались. */ function syncStickyOffsets(){ var h = document.querySelector('header'); if(!h) return; document.documentElement.style.setProperty('--head-h', Math.round(h.offsetHeight)+'px'); } function showSection(id){ $$('main section').forEach(function(s){ s.classList.toggle('active', s.id===id); }); $$('#tabs button').forEach(function(b){ b.classList.toggle('active', b.dataset.sec===id); }); if(history.replaceState) history.replaceState(null,'','#'+id); window.scrollTo({top:0, behavior:'smooth'}); if(id==='sec-unions'){ loadUnions(); if(window.PultPartnery) window.PultPartnery.load(); } if(id==='sec-roadmap' && window.PultRoadmap) window.PultRoadmap.load(); if(id==='sec-agenty'){ if(window.PultProvaydery) window.PultProvaydery.load(false); renderAgenty(); renderInformKontrol(); } if(id==='sec-analytics') renderForecast(); if(id==='sec-docs') renderDocs(false); if(id==='sec-otchety' && window.PultOtchety) window.PultOtchety.load(); } function applyTheme(t){ document.documentElement.setAttribute('data-theme', t); var bt = $('btn-theme'); if(bt){ bt.textContent = t==='dark' ? '🌙' : '☀️'; var ttl = t==='dark' ? 'Тёмная тема — нажмите, чтобы включить светлую' : 'Светлая тема — нажмите, чтобы включить тёмную'; bt.title = ttl; bt.setAttribute('aria-label', ttl); } document.querySelector('meta[name=theme-color]').setAttribute('content', t==='dark'?'#0b1120':'#ffffff'); Store.set('theme', t); if(S.audit){ renderCharts(); } } function renderAll(){ syncStickyOffsets(); renderHealth(); renderKPI(); renderFunnel(); renderCharts(); renderRecs(); renderChannels(); renderBots(); renderBotChannels(); renderPosticMetrics(); renderThemeOptions(); renderSources(); renderRoutes(); renderFilterLists(); renderServerHistory(); renderRunList(); renderChecks(); renderLandings(); $('poll-int').value = S.pollInterval||60; $('settings-info').textContent = 'Сейчас в channel_relay.json: '+(S.pollInterval||'—')+' с. '+ 'Маршрутов включено: '+num(S.donorsEnabled)+'.'; if($('sec-analytics').classList.contains('active')) renderForecast(); } function renderThemeOptions(){ var sel = $('f-theme'); if(sel.options.length>1) return; var themes = {}; relayRoutes().forEach(function(r){ if(r.theme) themes[r.theme]=1; }); Object.keys(themes).sort().forEach(function(t){ var o=document.createElement('option'); o.value=t; o.textContent=t; sel.appendChild(o); }); } function bind(){ $$('#tabs button').forEach(function(b){ b.addEventListener('click', function(){ showSection(b.dataset.sec); }); }); var fd = $('foot-docs'); if(fd) fd.addEventListener('click', function(e){ e.preventDefault(); showSection('sec-docs'); }); $$('.seg button').forEach(function(b){ b.addEventListener('click', function(){ $$('.seg button').forEach(function(x){ x.setAttribute('aria-pressed','false'); }); b.setAttribute('aria-pressed','true'); S.windowMinutes = parseInt(b.dataset.win,10); $('win-min').value = S.windowMinutes; Store.set('win', S.windowMinutes); refreshAll(false); }); }); $('btn-refresh-data').addEventListener('click', function(){ var b = $('btn-refresh-data'); busy(b, true, 'обновляю…'); refreshAll(true).then(function(){ busy(b, false); }, function(){ busy(b, false); }); }); window.addEventListener('resize', syncStickyOffsets); $('btn-theme').addEventListener('click', function(){ applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark'); }); $('q').addEventListener('input', renderChannels); $('q-src').addEventListener('input', renderSources); $('f-theme').addEventListener('change', renderSources); $('q-routes').addEventListener('input', renderRoutes); $('union-q').addEventListener('input', loadUnions); $('f-model').addEventListener('change', renderForecast); $('btn-check-bots').addEventListener('click', function(){ var b = $('btn-check-bots'), st = $('bots-status'); busy(b, true, 'проверяю ботов…'); st.textContent = 'опрашиваю Telegram через pult-bots-health, это занимает до минуты…'; checkAllBots() .catch(function(e){ st.textContent = 'Проверка не прошла: '+errText(e); }) .then(function(){ busy(b, false); }); }); $('btn-check-live').addEventListener('click', function(){ var b = $('btn-check-live'), st = $('bots-status'); busy(b, true, 'проверяю каналы…'); st.textContent = 'запрашиваю channels-liveness…'; loadLiveness() .then(function(d){ st.textContent = 'Каналы проверены '+fmtTime(d.checked_at)+': живых '+ num(d.alive)+' из '+num(d.total)+'.'; recompute(); renderAll(); }) .catch(function(e){ st.textContent = 'Проверка каналов не прошла: '+errText(e); }) .then(function(){ busy(b, false); }); }); $('btn-docs-recheck').addEventListener('click', function(){ var b = $('btn-docs-recheck'); busy(b, true, 'проверяю…'); renderDocs(true); setTimeout(function(){ busy(b, false); }, 1200); }); $('btn-docs-html').addEventListener('click', function(){ downloadHtmlReport(); $('docs-status').textContent = 'HTML-отчёт выгружен '+fmtTime(nowIso())+'.'; }); $('btn-docs-json').addEventListener('click', function(){ download('soyuz_pult_report_'+Date.now()+'.json', JSON.stringify(reportObject(),null,2),'application/json'); $('docs-status').textContent = 'JSON-отчёт выгружен '+fmtTime(nowIso())+'.'; }); $('btn-docs-hist').addEventListener('click', function(){ var h = mhistAll(); if(!h.length){ $('docs-status').textContent = 'История замеров пуста — выгружать нечего.'; return; } download('soyuz_pult_history_'+Date.now()+'.json', JSON.stringify(h,null,2),'application/json'); $('docs-status').textContent = 'Выгружено замеров: '+num(h.length)+'.'; }); $('btn-run-calib').addEventListener('click', function(){ var b=$('btn-run-calib'); b.disabled=true; b.textContent='Идёт прогон…'; runCalibration().catch(function(e){ $('calib-progress').textContent='Сбой прогона: '+errText(e); }) .then(function(){ b.disabled=false; b.textContent='▶ Прогон калибровки'; }); }); $('btn-dl-report').addEventListener('click', downloadHtmlReport); $('btn-export').addEventListener('click', function(){ download('soyuz_pult_report_'+Date.now()+'.json', JSON.stringify(reportObject(),null,2),'application/json'); }); $('btn-copy-report').addEventListener('click', function(){ navigator.clipboard.writeText($('report-body').textContent||'').then(function(){ $('report-meta').textContent='Текст отчёта скопирован.'; }); }); $('btn-readiness').addEventListener('click', function(){ var b = $('btn-readiness'); busy(b, true, 'запрашиваю…'); $('report-meta').textContent='запрашиваю отчёт готовности с сервера…'; Api.get('/api/v1/parsic/readiness-report?regenerate=true', 120000) .then(function(d){ $('report-body').textContent = (d.markdown||'').slice(0,60000)||'Отчёт пуст.'; $('report-meta').textContent='Отчёт готовности получен '+fmtTime(nowIso())+'.'; }) .catch(function(e){ $('report-meta').textContent='Не удалось: '+errText(e); }) .then(function(){ busy(b, false); }); }); $('btn-save-poll').addEventListener('click', function(){ var b = $('btn-save-poll'); var v = parseInt($('poll-int').value,10); if(!v || v<1 || v>3600){ $('settings-info').textContent='Допустимо 1…3600 секунд.'; return; } busy(b, true, 'сохраняю…'); Api.post('/api/v1/parsic/relay-settings-patch', {poll_interval_seconds:v, comment:'изменено из пульта'}, 20000) .then(function(){ $('settings-info').textContent='Сохранено: '+v+' с. Parsic подхватит на следующем цикле.'; return refreshAll(false); }) .catch(function(e){ $('settings-info').textContent='Ошибка: '+errText(e); }) .then(function(){ busy(b, false); }); }); $('btn-add-source').addEventListener('click', function(){ var body = {url:$('src-url').value.trim(), union:$('src-union').value.trim(), targets:$('src-targets').value.split(',').map(function(s){return s.trim();}).filter(Boolean), theme:$('src-theme').value.trim(), include_keywords:[], enabled:true}; if(!body.url || !body.targets.length){ $('add-source-status').textContent='Нужны ссылка и хотя бы один канал.'; return; } var ba = $('btn-add-source'); busy(ba, true, 'добавляю…'); $('add-source-status').textContent='добавляю донора, это может занять до полутора минут…'; Api.post('/api/v1/parsic/custom-source', body, 90000) .then(function(d){ $('add-source-status').textContent = d.message||'Готово.'; $('add-source-result').innerHTML = '
Маршрутов создано: '+ (d.routes_count||0)+'. '+esc(d.note||'')+'
'; return refreshAll(false); }) .catch(function(e){ $('add-source-status').textContent='Ошибка: '+errText(e); }) .then(function(){ busy(ba, false); }); }); $('btn-create-landing').addEventListener('click', function(){ var bl = $('btn-create-landing'); var name = $('src-union').value.trim() || prompt('Название союза:'); if(!name) return; busy(bl, true, 'создаю…'); $('add-source-status').textContent='создаю лендинг союза…'; Api.post('/api/v1/parsic/create-landing', {name:name, url:$('src-url').value.trim()}, 60000) .then(function(d){ d = d || {}; var url = d.public_url || d.url || ''; var ttl = d.name || d.slug || name; $('add-source-status').textContent = 'Лендинг создан.'; $('add-source-result').innerHTML = '
'+ 'Лендинг создан'+(d.slug?(' · '+esc(d.slug)+''):'')+ (url ? ('
'+ '🔗 Лендинг — '+esc(ttl)+''+ ''+ ''+ '
') : '
Сервер не вернул адрес страницы — '+ 'посмотрите список в разделе «Документы и отчёты».
')+ '
'; var cb = $('btn-copy-landing'); if(cb) cb.addEventListener('click', function(){ copyLandingUrl(cb.dataset.url, $('copy-landing-status')); }); // Кэш лендингов обновляем сразу — чип появится и в остальных списках без перезагрузки. return loadLandings().then(function(){ renderLandings(); renderAll(); }); }) .catch(function(e){ $('add-source-status').textContent='Ошибка: '+errText(e); }) .then(function(){ busy(bl, false); }); }); $('fe-save').addEventListener('click', saveRouteModal); $('fe-close').addEventListener('click', function(){ $('route-modal').classList.remove('open'); }); $('ef-save').addEventListener('click', saveEntityFilters); $('ef-close').addEventListener('click', function(){ $('entity-filter-modal').classList.remove('open'); }); $('ef-include-btn').addEventListener('click', function(){ var v=$('ef-include-add').value.trim(); if(v){ efState.inc.push(v); $('ef-include-add').value=''; drawEfChips(); } }); $('ef-exclude-btn').addEventListener('click', function(){ var v=$('ef-exclude-add').value.trim(); if(v){ efState.exc.push(v); $('ef-exclude-add').value=''; drawEfChips(); } }); $('auto-live').addEventListener('change', function(){ Store.set('auto', $('auto-live').checked); setupAuto(); }); $('allow-demo').addEventListener('change', function(){ Store.set('demo', $('allow-demo').checked); }); $('btn-clear-hist').addEventListener('click', function(){ if(confirm('Очистить историю прогонов калибровки?')){ Store.del('runs'); renderRunList(); } }); $('btn-logout').addEventListener('click', doLogout); // «Выйти» только в разделе «Настройки» $('logs-refresh').addEventListener('click', loadBotLogs); $('logs-close').addEventListener('click', function(){ $('logs-modal').classList.remove('open'); }); $$('#tbl-sources th[data-k]').forEach(function(th){ th.addEventListener('click', function(){ srcSort.dir = (srcSort.k===th.dataset.k)? -srcSort.dir : -1; srcSort.k = th.dataset.k; renderSources(); }); }); $$('#tbl-routes th[data-k]').forEach(function(th){ th.addEventListener('click', function(){ routeSort.dir = (routeSort.k===th.dataset.k)? -routeSort.dir : 1; routeSort.k = th.dataset.k; renderRoutes(); }); }); $$('.modal').forEach(function(m){ m.addEventListener('click', function(e){ if(e.target===m) m.classList.remove('open'); }); }); document.addEventListener('keydown', function(e){ if(e.key==='Escape') $$('.modal.open').forEach(function(m){ m.classList.remove('open'); }); }); } var autoTimer = null; function setupAuto(){ if(autoTimer){ clearInterval(autoTimer); autoTimer=null; } if($('auto-live').checked) autoTimer = setInterval(function(){ refreshAll(false); }, 60000); } /* ============================================================ 12а. ДОНОРЫ ↔ КАНАЛЫ (donor-matrix + route-toggle) ============================================================ */ var DM = {data:null, err:''}; function loadDonorMatrix(){ return Api.get('/api/v1/parsic/donor-matrix?days=7&_='+Date.now(), 45000) .then(function(d){ DM.data=d||null; DM.err=''; renderDonorMatrix(); return d; }) .catch(function(e){ DM.err=errText(e); renderDonorMatrix(); throw e; }); } /* В опалубка-пульте матрица фильтруется темой так же, как relayRoutes(). */ function dmChannels(){ var chs = (DM.data && DM.data.channels) || []; if(!CFG.themeFilter) return chs; return chs.map(function(c){ var tc = String(c.channel||'').toLowerCase(); var ds = (c.donors||[]).filter(function(d){ return String(d.theme||'').indexOf(CFG.themeFilter)>=0 || tc.indexOf(CFG.themeFilter)>=0; }); return {channel:c.channel, donors:ds}; }).filter(function(c){ return c.donors.length; }); } var DM_KLASSES = [['predlozhenie','Предл.'],['zapros','Запрос'],['novosti','Новости'],['reklama','Реклама']]; function renderDonorMatrix(){ var box = $('donor-matrix'); if(!box) return; if(!DM.data){ box.innerHTML = '
'+ (DM.err ? ('Матрица недоступна: '+esc(DM.err)) : 'Загружаю матрицу…')+'
'; return; } var html = ''; if(DM.data.stats_error) html += '
Статистика Redis недоступна: '+ esc(DM.data.stats_error)+' — счётчики показаны нулями.
'; dmChannels().forEach(function(c){ var tot = {predlozhenie:0,zapros:0,novosti:0,reklama:0}, total = 0, flow = 0; (c.donors||[]).forEach(function(d){ DM_KLASSES.forEach(function(k){ tot[k[0]] += (d.counts && d.counts[k[0]]) || 0; }); total += d.total||0; flow += d.approx_24h||0; }); html += '
'+tgLink(c.channel)+''+ ' · доноров: '+num((c.donors||[]).length)+' · поток за 24ч: '+num(flow)+'
'+ '
'+ DM_KLASSES.map(function(k){ return ''; }).join('')+ ''+ (c.donors||[]).map(function(d){ return ''+ ''+ ''+ DM_KLASSES.map(function(k){ return ''; }).join('')+ ''+ ''+ ''; }).join('')+ ''+ DM_KLASSES.map(function(k){ return ''; }).join('')+ ''+ '
ДонорВкл'+k[1]+'ВсегоЗа 24ч
'+tgLink(d.source_id)+(d.source_title?(' '+ esc(d.source_title)+''):'')+''+(d.enabled?'вкл':'выкл')+''+(d.no_stats?'—':num((d.counts && d.counts[k[0]])||0))+''+(d.no_stats?'—':num(d.total||0))+''+(d.no_stats?'—':num(d.approx_24h||0))+'
Итого по каналу'+num(tot[k[0]])+''+num(total)+''+num(flow)+'
'; }); box.innerHTML = html || '
Маршрутов для матрицы не найдено.
'; $$('#donor-matrix [data-dm-s]').forEach(function(b){ b.addEventListener('click', function(){ var on = b.dataset.dmOn === '1'; if(!confirm((on?'Включить':'Выключить')+' маршрут '+b.dataset.dmS+' → '+b.dataset.dmC+'?')) return; busy(b, true, 'применяю…'); Api.post('/api/v1/parsic/route-toggle', {source_id:b.dataset.dmS, target_channel:b.dataset.dmC, enabled:on}, 20000) .then(function(r){ ((DM.data && DM.data.channels) || []).forEach(function(c){ if(String(c.channel).toLowerCase() !== String(r.target_channel).toLowerCase()) return; (c.donors||[]).forEach(function(d){ if(String(d.source_id).toLowerCase() === String(r.source_id).toLowerCase()) d.enabled = !!r.enabled; }); }); renderDonorMatrix(); $('donor-matrix-status').textContent = 'Маршрут '+r.source_id+' → '+r.target_channel+ ' теперь '+(r.enabled?'включён':'выключен')+'. Parsic подхватит без рестарта.'; }) .catch(function(e){ busy(b, false); $('donor-matrix-status').textContent = 'Ошибка: '+errText(e); }); }); }); } $('btn-donor-matrix').addEventListener('click', function(){ var b = $('btn-donor-matrix'); busy(b, true, 'загружаю…'); loadDonorMatrix().catch(function(){}).then(function(){ busy(b, false); }); }); /* ============================================================ 13. АВТОРИЗАЦИЯ И СТАРТ ============================================================ */ function lock(msg){ document.body.classList.add('pult-locked'); var g=$('pult-auth-gate'); g.classList.add('open'); g.setAttribute('aria-hidden','false'); $('pult-auth-err').textContent = msg || ''; $('pult-auth-pw').value = ''; setTimeout(function(){ $('pult-auth-pw').focus(); }, 60); } function unlock(){ document.body.classList.remove('pult-locked'); var g=$('pult-auth-gate'); g.classList.remove('open'); g.setAttribute('aria-hidden','true'); } var booted = false; function boot(){ if(booted){ refreshAll(false); return; } // повторный вход после 401 — без дублей слушателей booted = true; bootOnce(); } function bootOnce(){ applyTheme(Store.get('theme','dark')); S.windowMinutes = Store.get('win', 1440); $('win-min').value = S.windowMinutes; $$('.seg button').forEach(function(b){ b.setAttribute('aria-pressed', String(parseInt(b.dataset.win,10)===S.windowMinutes)); }); $('auto-live').checked = !!Store.get('auto', false); $('allow-demo').checked = Store.get('demo', false)===true; bind(); setupAuto(); syncStickyOffsets(); renderRunList(); if(location.hash){ var id=location.hash.replace('#',''); if($(id) && $(id).tagName==='SECTION') showSection(id); } loadModels() .then(function(){ return loadRegistry(); }) // единый реестр СОЮЗа (04.09) .then(function(){ return loadLandings(); }) // чипы «Лендинг» доступны с первой отрисовки .then(function(){ return refreshAll(false); }) .then(function(){ return loadDonorMatrix().catch(function(){}); }); } /* Пароль никуда не отправляется: из него считается SHA-256, токен идёт в заголовке X-Pult-Auth. Проверяет пароль сервер — при 401/403 токен стирается и окно ввода открывается снова. Enter работает как «Войти». */ $('pult-auth-form').addEventListener('submit', function(e){ e.preventDefault(); var pw = $('pult-auth-pw').value; if(!pw){ $('pult-auth-err').textContent = 'Введите пароль'; return; } $('pult-auth-err').textContent = 'проверяю…'; sha256hex(pw).then(function(token){ setAuthToken(token); $('pult-auth-pw').value = ''; $('pult-auth-err').textContent = ''; unlock(); boot(); }).catch(function(err){ $('pult-auth-err').textContent = (err && err.message) || 'не удалось посчитать хеш пароля'; }); }); if(isPultHost() && !authOk()){ lock(); } else { unlock(); boot(); }