абузы: free-панели new-api на вкладке (роут /api/panels + грид), CSS вынесен из JS, панельный классификатор даёт freeapi-вес, тесты
This commit is contained in:
@@ -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 ---------- */
|
||||
|
||||
Reference in New Issue
Block a user