абузы: free-панели new-api на вкладке (роут /api/panels + грид), CSS вынесен из JS, панельный классификатор даёт freeapi-вес, тесты
Some checks failed
ci / test (3.10) (push) Has been cancelled
ci / test (3.11) (push) Has been cancelled
ci / test (3.12) (push) Has been cancelled

This commit is contained in:
Vlad Gothbreach
2026-10-27 02:06:32 +03:00
parent b09cb5f873
commit 16512bf4a1
21 changed files with 1386 additions and 130 deletions

View File

@@ -210,6 +210,11 @@ label.chk{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font
.top{padding:8px 10px}
.crumb{display:none}
}
.pn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px;margin-top:8px}
.pn{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;text-decoration:none;color:inherit;font-size:12px}
.pn:hover{border-color:var(--acc)}
.pn b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pn .muted{opacity:.6}
</style>
</head>
<body>
@@ -261,6 +266,11 @@ const dt=s=>{if(!s)return"";s=String(s);const m=s.match(/^(\d{4})-(\d{2})-(\d{2}
const ago=s=>{if(!s)return"";const t=Date.parse(!isNaN(s)?+s:s);if(!t)return dt(s);
const d=(Date.now()-t)/1000;if(d<3600)return Math.max(1,Math.round(d/60))+" мин";if(d<86400)return Math.round(d/3600)+" ч";return Math.round(d/86400)+" дн"};
const debounce=(f,ms=220)=>{let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>f(...a),ms)}};
/* статус прогона: "ok*" — успех; "пусто"/"empty" — пустой ответ (не ошибка); "" — нет статуса */
const isOkSt=s=>String(s==null?"":s).trim().toLowerCase().startsWith("ok");
const isEmpSt=s=>["пусто","empty"].includes(String(s==null?"":s).trim().toLowerCase());
const stKey=s=>{const v=String(s==null?"":s).trim();return !v?"none":(isOkSt(v)?"ok":(isEmpSt(v)?"empty":"err"))};
const stPill=s=>{const k=stKey(s);return k==="ok"?"ok":(k==="err"?"no":"unk")};
let TOAST_T;
function toast(msg,bad=false){const el=$("#toast");el.textContent=msg;el.classList.toggle("bad",!!bad);el.classList.add("on");clearTimeout(TOAST_T);TOAST_T=setTimeout(()=>el.classList.remove("on"),2600);}
@@ -303,9 +313,9 @@ const NAV=[
{id:"digest", label:"Дайджест", icon:"M6 3h9l4 4v14H6zM15 3v4h4"},
{id:"watch", label:"Watchlist", icon:"M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z"}
];
const S={view:"overview",q:"",cat:"",type:"all",sort:"score",days:0,priced:false,
const S={view:"overview",q:"",cat:"",type:"all",sort:"score",days:0,priced:false,src:"",
offset:0,items:[],total:0,auto:localStorage.getItem("omni.auto")==="1",
types:[],cats:[]};
runQ:"",runF:"all",types:[],cats:[]};
function route(){
const h=location.hash.replace(/^#\/?/,"");
@@ -314,6 +324,7 @@ function route(){
S.view=NAV.some(n=>n.id===v)?v:"overview";
S.q=p.get("q")||""; S.cat=p.get("cat")||""; S.type=p.get("type")||"all";
S.sort=p.get("sort")||"score"; S.days=+(p.get("days")||0); S.priced=p.get("priced")==="1";
S.src=p.get("src")||"";
S.offset=0;
$("#q").value=S.q;
$(".crumb").innerHTML="<b>"+esc(NAV.find(n=>n.id===S.view).label)+"</b>"+(S.q?` · «${esc(S.q)}»`:"");
@@ -328,6 +339,7 @@ function go(view,params={},replace=false){
const sort=params.sort??S.sort; if(sort!=="score")p.set("sort",sort);
const days=params.days??S.days; if(days)p.set("days",days);
if((params.priced??S.priced))p.set("priced","1");
const src=params.src!==undefined?params.src:S.src; if(src)p.set("src",src);
const url="#/"+view+(p.toString()?"?"+p:"");
if(replace)history.replaceState(null,"",url); else location.hash=url;
if(replace)route();
@@ -376,10 +388,12 @@ V.overview=async()=>{
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
]);
S.stats=st;
const day=tl.last24||tl.points[tl.points.length-1]?.n||0, prev=tl.points[tl.points.length-2]?.n||0;
const dPct=prev?Math.round((day-prev)/prev*100):null;
// last24 — честное скользящее окно от бэкенда; «сегодня» неполный день, процент не считаем
const day=tl.last24??0;
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(0);
const okRate=st.runs?(st.runs_ok/st.runs*100).toFixed(0):0;
const bs=runs.by_status||{};
const runAll=(bs.ok||0)+(bs.empty||0)+(bs.err||0)+(bs.none||0);
const okRate=runAll?Math.round((bs.ok||0)/runAll*100):(st.runs?Math.round(st.runs_ok/st.runs*100):0);
const kpi=(label,val,sub,accent)=>`<div class="card hov"><div class="sub">${label}</div>
<div class="kpi num" style="${accent?`color:${accent}`:""}">${val}</div><div class="sub">${sub}</div></div>`;
const hits=(watch.rules||[]).reduce((a,r)=>a+(r.count||0),0);
@@ -390,7 +404,7 @@ V.overview=async()=>{
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
</div></div>
<div class="grid g4">
${kpi("Записей всего",num(st.items),`+${num(day)} за сутки импорта${dPct===null?"":` <span class="${dPct>=0?"up":"down"}">${dPct>=0?"▲":"▼"} ${Math.abs(dPct)}%</span>`}`)}
${kpi("Записей всего",num(st.items),`за последние 24 часа: +${num(day)}`)}
${kpi("Источников живых",`${num(st.alive)}<small>/ ${num(st.sources)}</small>`,`${live}% реестра отдают записи`)}
${kpi("Ключей готово",`${num(st.keys?.ready||0)}<small>/ ${num(st.keys?.keys||0)}</small>`,`${num(st.keys?.services||0)} сервисов · ротация и остывание`)}
${kpi("Watchlist",num((watch.rules||[]).length),`${num(hits)} совпадений по правилам`)}
@@ -413,10 +427,10 @@ V.overview=async()=>{
<div class="grid g2" style="margin-top:14px">
<div class="card"><h3>Свежее <span class="r"><a href="#/feed">вся лента →</a></span></h3><div id="ov-latest">${skel(4)}</div></div>
<div class="card"><h3>Последние прогоны <span class="r"><a href="#/runs">все →</a></span></h3>
<div class="card"><h3>Последние прогоны <span class="r">ok ${num(okRate)}% · <a href="#/runs">все →</a></span></h3>
<table><tbody>${(runs.items||[]).map(r=>`<tr><td class="mut mono nowrap">${esc(r.when||"")}</td>
<td class="nowrap">${esc(r.source||"—")}</td><td class="right mono">${r.got??""}/${r.kept??""}</td>
<td class="right"><span class="pill ${r.status==="ok"?"ok":"no"}"></span><span class="dim">${esc(r.status||"")}</span></td></tr>`).join("")}</tbody></table>
<td class="right"><span class="pill ${stPill(r.status)}" title="${esc(r.status||"нет статуса")}"></span><span class="dim">${esc(r.status||"—")}</span></td></tr>`).join("")}</tbody></table>
</div>
</div>
@@ -441,8 +455,8 @@ V.feed=async()=>{
const box=$("#view");
box.innerHTML=`<div class="page-h"><div><h2>Лента</h2><p>Все записи реестра: фильтры по типу, периоду, сортировке; клик — детали и соседи.</p></div>
<div class="right">
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">CSV</a>
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра, до 1000 записей">JSON</a>
<a class="btn sm" href="/api/export.csv?${exQS()}" download title="Экспорт текущего фильтра: период, тип, источник, цена; до 1000 записей">CSV</a>
<a class="btn sm" href="/api/export.json?${exQS()}" download title="Экспорт текущего фильтра: период, тип, источник, цена; до 1000 записей">JSON</a>
</div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<span class="dim" style="font-size:11.5px">Типы:</span>
@@ -456,12 +470,15 @@ V.feed=async()=>{
<div class="card" style="padding:0"><div id="list">${skel(6)}</div><div id="more" class="pager"></div></div>`;
await loadItems(true);
};
const exQS=()=>new URLSearchParams({q:S.q,category:S.cat,type:S.type,sort:S.sort,...(S.priced?{priced:"1"}:{})}).toString();
const exQS=()=>new URLSearchParams({q:S.q,category:S.cat,type:S.type,sort:S.sort,
...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),...(S.src?{src:S.src}:{}),
limit:"1000"}).toString();
async function loadItems(reset){
if(reset){S.offset=0;S.items=[]}
const p=new URLSearchParams({limit:"40",offset:String(S.offset),q:S.q,sort:S.sort,type:S.type,
...(S.cat?{category:S.cat}:{}),...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{})});
...(S.cat?{category:S.cat}:{}),...(S.days?{days:String(S.days)}:{}),...(S.priced?{priced:"1"}:{}),
...(S.src?{src:S.src}:{})});
const d=await api("/api/items?"+p);
S.total=d.total; S.items=S.items.concat(d.items); S.offset+=d.items.length;
const list=$("#list"); if(!list)return;
@@ -475,8 +492,8 @@ function findChips(f){
if(!f) return "";
const p=[];
(f.codes||[]).slice(0,2).forEach(c=>p.push(`<span class="chip promo" title="промокод/код">code: ${esc(c)}</span>`));
if(f.amount) p.push(`<span class="chip credits">${esc(f.amount)}</span>`);
if(f.term) p.push(`<span class="chip invite">${esc(f.term)}</span>`);
if(f.amount) p.push(`<span class="chip price" title="объём/сумма">${esc(f.amount)}</span>`);
if(f.term) p.push(`<span class="chip trial" title="срок">${esc(f.term)}</span>`);
return p.join("");
}
function itemRow(it,compact){
@@ -536,6 +553,7 @@ async function openItem(id){
function closeDrawer(){$("#drawer").classList.remove("on");$("#backdrop").classList.remove("on");}
/* ---------- abuse ---------- */
V.abuse=async()=>{
const d=await api("/api/abuse?limit=120");
const tags=Object.entries(d.tags||{}).slice(0,14);
@@ -547,9 +565,24 @@ V.abuse=async()=>{
${tags.map(([t,n])=>`<span class="chip click" data-tag="${esc(t)}">${esc(d.labels?.[t]||t)} <span class="n">${num(n)}</span></span>`).join("")}
</div></div>
<div class="card" style="padding:0"><div id="ab-list">${(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пока пусто</div>`}</div></div>
<div id="pn-box"></div>
<div id="sum-box"></div>`;
S.abuse=(d.items||[]);
loadPanels();
};
async function loadPanels(){
const box=$("#pn-box"); if(!box) return;
try{
const d=await api("/api/panels");
const all=(d.panels||[]).filter(p=>(p.free_count||p.free||0)>0);
const rows=all.slice(0,40);
if(!rows.length){ box.innerHTML=""; return; }
box.innerHTML=`<div class="card" style="margin-top:14px"><h3>Free-панели new-api <span class="r">всего ${num(d.total)} · с бесплатными ${num(all.length)}${all.length>rows.length?` · показано ${num(rows.length)}`:""}</span></h3>
<div class="pn-grid">${rows.map(p=>`<a class="pn" href="https://${esc(p.host||p.url||"")}" target="_blank" rel="noopener">
<b>${esc(p.host||p.url||"?")}</b><span class="mono">${num(p.free_count||p.free||0)} free</span>
<span class="muted">${num(p.models||p.model_count||0)} моделей</span></a>`).join("")}</div></div>`;
}catch(e){ box.innerHTML=""; }
}
async function abuseByTag(tag){
const d=await api("/api/abuse?limit=60"+(tag?"&tag="+encodeURIComponent(tag):""));
const box=$("#ab-list"); if(box) box.innerHTML=(d.items||[]).map(it=>itemRow(it)).join("")||`<div class="empty">пусто</div>`;
@@ -644,31 +677,36 @@ async function srcDetail(name){
/* ---------- runs ---------- */
V.runs=async()=>{
const d=await api("/api/runs?limit=200");
const ok=(d.items||[]).filter(r=>r.status==="ok").length;
const bs=d.by_status||{};
const items=d.items||[];
const ok=bs.ok??items.filter(r=>isOkSt(r.status)).length; // "ok", "ok-304" — успех
const empty=bs.empty??items.filter(r=>stKey(r.status)==="empty").length;
const err=bs.err??items.filter(r=>stKey(r.status)==="err").length;
const none=bs.none??items.filter(r=>stKey(r.status)==="none").length;
const rf=(k,l,n)=>`<span class="chip click ${S.runF===k?"on":""}" data-rf="${k}">${l} <span class="n">${num(n)}</span></span>`;
$("#view").innerHTML=`
<div class="page-h"><div><h2>Прогоны</h2><p>Журнал сборов: что запросили, сколько получили и сколько нового легло в базу.</p></div>
<div class="right">
<span class="chip click on" data-rf="all">все <span class="n">${num(d.total)}</span></span>
<span class="chip click" data-rf="ok">ok <span class="n">${ok}</span></span>
<span class="chip click" data-rf="err">ошибки <span class="n">${d.total-ok}</span></span></div></div>
${rf("all","все",d.total)}${rf("ok","ok",ok)}${empty?rf("empty","пусто",empty):""}${rf("err","ошибки",err)}${none?rf("none","без статуса",none):""}</div></div>
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px">
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)}</span></div></div>
<input id="r-q" placeholder="фильтр: источник или запрос" style="min-width:280px" value="${esc(S.runQ||"")}">
<span class="spacer"></span><span class="dim mono">всего ${num(d.total)} · показано ${num(items.length)}</span></div></div>
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="r-tbl"><thead><tr>
<th>когда</th><th>источник</th><th>запрос</th><th class="right">got</th><th class="right">kept</th><th class="right">байт</th><th>статус</th></tr></thead>
<tbody>${(d.items||[]).map(r=>`<tr data-st="${esc(r.status||"")}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
<tbody>${items.map(r=>`<tr data-st="${stKey(r.status)}"><td class="mono dim nowrap">${esc(r.when||"")}</td><td>${esc(r.source||"—")}</td>
<td class="dim" style="max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.query||"")}</td>
<td class="right mono">${r.got??""}</td><td class="right mono">${r.kept??""}</td><td class="right mono dim">${r.bytes?num(r.bytes):""}</td>
<td><span class="pill ${r.status==="ok"?"ok":"no"}"></span>${esc(r.status||"")}</td></tr>`).join("")}</tbody></table></div></div>`;
S.runQ=""; S.runF="all";
<td><span class="pill ${stPill(r.status)}"></span>${esc(r.status||"—")}</td></tr>`).join("")}</tbody></table></div></div>`;
// S.runQ/S.runF не сбрасываем: автообновление перерисовывает раздел каждые 60 с
const applyRuns=()=>{const v=(S.runQ||"").toLowerCase();
$$("#r-tbl tbody tr").forEach(tr=>{
const okT=!v||tr.textContent.toLowerCase().includes(v);
const okS=S.runF==="all"||(S.runF==="ok"?tr.dataset.st==="ok":tr.dataset.st!=="ok");
const okS=S.runF==="all"||S.runF===tr.dataset.st;
tr.style.display=(okT&&okS)?"":"none";});};
$("#r-q").addEventListener("input",debounce(e=>{S.runQ=e.target.value;applyRuns()},120));
$$("#view [data-rf]").forEach(c=>c.addEventListener("click",()=>{
$$("#view [data-rf]").forEach(x=>x.classList.remove("on"));c.classList.add("on");S.runF=c.dataset.rf;applyRuns();}));
applyRuns();
};
/* ---------- telegram ---------- */