1024 lines
72 KiB
HTML
1024 lines
72 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>omni · комбайн источников</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0a0e13; --bg2:#0c1219; --panel:#111823; --panel2:#0e151e; --line:#1c2735; --line2:#27364a;
|
||
--text:#e9eff7; --muted:#93a3b8; --dim:#7b8ba1;
|
||
--acc:#37d67a; --acc2:#5aa9ff; --warn:#f5b544; --bad:#ef5b5b; --violet:#a78bfa;
|
||
--r:10px; --r2:14px;
|
||
--mono:"Cascadia Mono",Consolas,"SF Mono",monospace;
|
||
--sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||
}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;height:100%}
|
||
body{background:var(--bg);color:var(--text);font:13.5px/1.5 var(--sans);-webkit-font-smoothing:antialiased}
|
||
a{color:var(--acc2);text-decoration:none}
|
||
a:hover{text-decoration:underline}
|
||
button,input,select,textarea{font:inherit;color:inherit}
|
||
::selection{background:#2b4a6f}
|
||
::-webkit-scrollbar{width:10px;height:10px}
|
||
::-webkit-scrollbar-thumb{background:#243244;border-radius:6px}
|
||
::-webkit-scrollbar-track{background:transparent}
|
||
|
||
/* shell */
|
||
.app{display:grid;grid-template-columns:236px 1fr;height:100vh;overflow:hidden}
|
||
.side{background:var(--bg2);border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0}
|
||
.brand{display:flex;align-items:center;gap:9px;padding:16px 16px 12px}
|
||
.logo{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#37d67a,#5aa9ff);display:grid;place-items:center;color:#07120c;font-weight:800;font-size:14px}
|
||
.brand h1{font-size:15px;margin:0;letter-spacing:.4px}
|
||
.brand .ver{font:10px var(--mono);color:var(--dim);border:1px solid var(--line2);border-radius:999px;padding:1px 6px}
|
||
nav{display:flex;flex-direction:column;gap:1px;padding:6px 8px;overflow:auto}
|
||
.nav-i{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;color:var(--muted);cursor:pointer;border:0;background:none;text-align:left;width:100%}
|
||
.nav-i:hover{background:#131c27;color:var(--text)}
|
||
.nav-i.on{background:#16222f;color:var(--text);box-shadow:inset 2px 0 0 var(--acc)}
|
||
.nav-i svg{flex:0 0 16px;opacity:.85}
|
||
.nav-i .k{margin-left:auto;font:10px var(--mono);color:var(--dim);border:1px solid var(--line);border-radius:4px;padding:0 4px}
|
||
.nav-i .badge{margin-left:auto;font:10px var(--mono);background:#1b2a1f;color:var(--acc);border-radius:999px;padding:1px 6px}
|
||
.side-foot{margin-top:auto;padding:10px 12px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px}
|
||
.health{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted)}
|
||
.dot{width:7px;height:7px;border-radius:50%;background:var(--acc);box-shadow:0 0 0 3px #37d67a22}
|
||
.dot.bad{background:var(--bad);box-shadow:0 0 0 3px #ef5b5b22}
|
||
.main{display:flex;flex-direction:column;min-width:0;min-height:0}
|
||
.top{display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#0e151e,#0b1119)}
|
||
.crumb{font-size:13px;color:var(--muted);white-space:nowrap}
|
||
.crumb b{color:var(--text);font-weight:600}
|
||
.search{position:relative;flex:1;max-width:560px}
|
||
.search input{width:100%;background:#0b1219;border:1px solid var(--line2);border-radius:9px;padding:8px 34px 8px 32px;outline:none}
|
||
.search input:focus{border-color:#35597f;box-shadow:0 0 0 3px #5aa9ff1f}
|
||
.search .ic{position:absolute;left:10px;top:8px;color:var(--dim)}
|
||
.search .kbd{position:absolute;right:8px;top:7px}
|
||
.sug{position:absolute;z-index:40;top:40px;left:0;right:0;background:#0f1822;border:1px solid var(--line2);border-radius:10px;box-shadow:0 18px 40px #0009;overflow:hidden;display:none}
|
||
.sug.on{display:block}
|
||
.sug .grp{font:10px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--dim);padding:7px 11px 3px}
|
||
.sug .it{padding:6px 11px;cursor:pointer;display:flex;gap:8px;align-items:center}
|
||
.sug .it:hover,.sug .it.sel{background:#17222f}
|
||
.sug .it .s{color:var(--dim);font-size:11px;margin-left:auto;white-space:nowrap}
|
||
.actions{display:flex;align-items:center;gap:7px;margin-left:auto}
|
||
.btn{display:inline-flex;align-items:center;gap:6px;background:#16202c;border:1px solid var(--line2);color:var(--text);border-radius:8px;padding:7px 11px;cursor:pointer;white-space:nowrap}
|
||
.btn:hover{background:#1b2836;border-color:#35506e}
|
||
.btn.primary{background:#12351f;border-color:#1f5c33;color:#b9f5cd}
|
||
.btn.primary:hover{background:#16452a}
|
||
.btn.ghost{background:transparent}
|
||
.btn.sm{padding:4px 8px;font-size:12px;border-radius:7px}
|
||
.btn.icon{padding:7px 8px}
|
||
.btn:disabled{opacity:.45;cursor:default}
|
||
.kbd{font:10.5px var(--mono);color:var(--dim);border:1px solid var(--line2);border-radius:5px;padding:1px 5px;background:#0b1119}
|
||
.content{overflow:auto;padding:18px;min-height:0}
|
||
.page-h{display:flex;align-items:flex-end;gap:12px;margin-bottom:14px;flex-wrap:wrap}
|
||
.page-h h2{margin:0;font-size:19px;letter-spacing:.2px}
|
||
.page-h p{margin:2px 0 0;color:var(--muted);font-size:12.5px;max-width:70ch}
|
||
.page-h .right{margin-left:auto;display:flex;gap:7px;align-items:center}
|
||
|
||
/* cards */
|
||
.grid{display:grid;gap:14px}
|
||
.g4{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||
.g3{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||
.g2{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||
.g21{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r2);padding:14px}
|
||
.card.hov:hover{border-color:#2b3d52}
|
||
.card h3{margin:0 0 10px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);font-weight:600;display:flex;align-items:center;gap:8px}
|
||
.card h3 .r{margin-left:auto;font:11px var(--mono);color:var(--dim);text-transform:none;letter-spacing:0}
|
||
.kpi{font-size:26px;font-weight:700;letter-spacing:-.5px;line-height:1.1}
|
||
.kpi small{font-size:12px;color:var(--muted);font-weight:500;margin-left:6px;letter-spacing:0}
|
||
.sub{color:var(--muted);font-size:12px;margin-top:3px}
|
||
.up{color:var(--acc)} .down{color:var(--bad)}
|
||
.num{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
||
|
||
/* chart */
|
||
.chart{display:flex;align-items:flex-end;gap:2px;height:96px;margin-top:8px}
|
||
.chart .b{flex:1;background:linear-gradient(180deg,#2f7d52,#1d4433);border-radius:2px 2px 0 0;min-height:2px;position:relative}
|
||
.chart .b:hover{background:linear-gradient(180deg,#4ade80,#25603f)}
|
||
.chart .b .tip{display:none;position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:#0b1119;border:1px solid var(--line2);border-radius:6px;padding:2px 6px;font:10px var(--mono);white-space:nowrap;z-index:5}
|
||
.chart .b:hover .tip{display:block}
|
||
.chart-x{display:flex;justify-content:space-between;color:var(--dim);font:10px var(--mono);margin-top:5px}
|
||
|
||
/* chips + tables */
|
||
.chip{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line2);border-radius:999px;padding:2px 8px;font-size:11px;color:var(--muted);background:#101925}
|
||
.chip.t{background:#141f2e;color:#a8c4e0}
|
||
.chip.cve{background:#2a1620;color:#f2a6b6;border-color:#4a2331}
|
||
.chip.leak{background:#2a1f12;color:#f3c07a;border-color:#4a3620}
|
||
.chip.tool{background:#132028;color:#8fd8e8;border-color:#234450}
|
||
.chip.model{background:#1b1630;color:#c2b2f7;border-color:#332a52}
|
||
.chip.promo{background:#12251a;color:#9fe6b4;border-color:#245035}
|
||
.chip.news{background:#151f2b;color:#a9bdd2;border-color:#26374b}
|
||
.chip.trial{background:#221a33;color:#cbb3f2;border-color:#3a2c58}
|
||
.chip.resale{background:#2a1626;color:#f0a8cf;border-color:#4a2340}
|
||
.chip.release{background:#12233a;color:#9dc4f5;border-color:#21405f}
|
||
.chip.guide{background:#12282a;color:#96dede;border-color:#1f4a4c}
|
||
.chip.dataset{background:#1a2438;color:#a9bdf0;border-color:#2c3c5c}
|
||
.chip.job{background:#2a2413;color:#ecd08a;border-color:#4a3f1f}
|
||
.chip.price{background:#12251a;color:#9fe6b4;border-color:#245035}
|
||
.chip.question{background:#1b2029;color:#b6c2d4;border-color:#2c3746}
|
||
.chip.on{background:#12351f;border-color:#1f5c33;color:#b9f5cd}
|
||
.chip.off{background:#211517;border-color:#4a2429;color:#f0a9a9}
|
||
.chip.click{cursor:pointer}
|
||
.chip.click:hover{border-color:#3c5a7c}
|
||
.chip .n{font-family:var(--mono);font-size:10px;color:inherit;opacity:.8}
|
||
table{width:100%;border-collapse:collapse}
|
||
th{position:sticky;top:0;background:#0e151e;text-align:left;font:11px var(--mono);letter-spacing:.05em;text-transform:uppercase;color:var(--dim);padding:8px 10px;border-bottom:1px solid var(--line);z-index:2}
|
||
td{padding:8px 10px;border-bottom:1px solid #151f2b;vertical-align:middle}
|
||
tbody tr:hover td{background:#0f1822}
|
||
.tbl-wrap{max-height:calc(100vh - 260px);overflow:auto;border:1px solid var(--line);border-radius:var(--r)}
|
||
td .nm{font-weight:500}
|
||
td .u{color:var(--dim);font-size:11px;display:block;max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.right{text-align:right}
|
||
.nowrap{white-space:nowrap}
|
||
.pill{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:1px}
|
||
.pill.ok{background:var(--acc)} .pill.no{background:var(--bad)} .pill.unk{background:#46566b}
|
||
.bar{height:5px;border-radius:3px;background:#1a2532;overflow:hidden;min-width:60px}
|
||
.bar>i{display:block;height:100%;background:var(--acc)}
|
||
|
||
/* feed */
|
||
.item{border-bottom:1px solid #151f2b;padding:11px 13px;cursor:pointer;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px}
|
||
.item:hover{background:#0f1822}
|
||
.item .t{font-size:14.5px;font-weight:600;letter-spacing:.1px;line-height:1.35;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||
.item .m{display:flex;gap:8px;align-items:center;flex-wrap:wrap;color:var(--dim);font-size:11.5px}
|
||
.item .src{color:#9db7d4}
|
||
.item .x{color:var(--muted);font-size:12.5px;margin-top:5px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||
.item .side{display:flex;flex-direction:column;align-items:flex-end;gap:6px;justify-content:center}
|
||
.sc{font:11px var(--mono);color:var(--muted);border:1px solid var(--line2);border-radius:6px;padding:1px 5px}
|
||
.sc.hi{color:#b9f5cd;border-color:#1f5c33;background:#12251a}
|
||
.sc.mid{color:#f3c07a;border-color:#4a3620;background:#221a10}
|
||
.ico{width:26px;height:26px;display:grid;place-items:center;border-radius:7px;border:1px solid transparent;color:var(--dim);cursor:pointer;background:none}
|
||
.ico:hover{background:#17222f;color:var(--text);border-color:var(--line2)}
|
||
|
||
/* drawer */
|
||
.drawer{position:fixed;top:0;right:0;bottom:0;width:min(640px,92vw);background:#0d141d;border-left:1px solid var(--line2);box-shadow:-30px 0 60px #0008;transform:translateX(102%);transition:transform .18s ease;z-index:60;display:flex;flex-direction:column}
|
||
.drawer.on{transform:none}
|
||
.drawer .dh{display:flex;gap:10px;align-items:flex-start;padding:14px 16px;border-bottom:1px solid var(--line)}
|
||
.drawer .dh h3{margin:0;font-size:15px;line-height:1.4;text-transform:none;letter-spacing:0;color:var(--text);font-weight:600}
|
||
.drawer .db{overflow:auto;padding:14px 16px;display:flex;flex-direction:column;gap:14px}
|
||
.drawer .txt{white-space:pre-wrap;font-size:13px;color:#cbd8e6;line-height:1.55}
|
||
.drawer .sec-h{font:11px var(--mono);text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin-bottom:6px}
|
||
.rel{display:flex;flex-direction:column;gap:6px}
|
||
.rel a{display:block;color:#b9cbe0;font-size:12.5px;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:#0f1822}
|
||
.rel a:hover{background:#131f2c;text-decoration:none;border-color:#2b3d52}
|
||
.backdrop{position:fixed;inset:0;background:#05090dbb;z-index:55;display:none}
|
||
.backdrop.on{display:block}
|
||
|
||
/* misc */
|
||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||
.spacer{flex:1}
|
||
.mut{color:var(--muted)} .dim{color:var(--dim)} .mono{font-family:var(--mono)}
|
||
.empty{padding:34px 16px;text-align:center;color:var(--muted)}
|
||
.empty b{display:block;color:var(--text);margin-bottom:4px;font-size:14px}
|
||
.skel{background:linear-gradient(90deg,#131d28,#1a2634,#131d28);background-size:200% 100%;animation:sk 1.2s infinite;border-radius:6px;height:12px}
|
||
@keyframes sk{0%{background-position:0 0}100%{background-position:-200% 0}}
|
||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%) translateY(20px);background:#12202b;border:1px solid #2b4762;color:var(--text);padding:9px 14px;border-radius:9px;box-shadow:0 12px 30px #000a;opacity:0;transition:.18s;z-index:90;font-size:12.5px}
|
||
.toast.on{opacity:1;transform:translateX(-50%)}
|
||
.toast.bad{border-color:#7a2a2a;background:#2a1416}
|
||
.modal{position:fixed;inset:0;z-index:80;display:none;place-items:center;background:#05090dd0}
|
||
.modal.on{display:grid}
|
||
.modal .box{width:min(720px,94vw);max-height:86vh;overflow:auto;background:#0d141d;border:1px solid var(--line2);border-radius:16px;padding:18px;box-shadow:0 30px 80px #000b}
|
||
.modal h3{margin:0 0 12px;font-size:16px}
|
||
.kv{display:grid;grid-template-columns:170px minmax(0,1fr);gap:6px 12px;font-size:12.5px}
|
||
.kv .k{color:var(--dim)}
|
||
.hl{background:#0f1822;border:1px solid var(--line);border-radius:10px;padding:10px 12px}
|
||
.hl .n{font-size:20px;font-weight:700;font-family:var(--mono)}
|
||
.hl .l{font-size:11.5px;color:var(--muted)}
|
||
.prog{height:6px;border-radius:4px;background:#16212d;overflow:hidden}
|
||
.prog>i{display:block;height:100%;background:linear-gradient(90deg,#37d67a,#5aa9ff);transition:width .2s}
|
||
.field{display:flex;flex-direction:column;gap:5px}
|
||
.field label{font-size:11.5px;color:var(--muted)}
|
||
input[type=text],input[type=number],select,textarea{background:#0b1219;border:1px solid var(--line2);border-radius:8px;padding:7px 9px;outline:none}
|
||
input:focus,select:focus,textarea:focus{border-color:#35597f;box-shadow:0 0 0 3px #5aa9ff1a}
|
||
textarea{min-height:72px;resize:vertical;font-family:var(--mono);font-size:12px}
|
||
label.chk{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;cursor:pointer}
|
||
.md{font-size:13.5px;line-height:1.6}
|
||
.md h1{font-size:18px;margin:0 0 10px}
|
||
.md h2{font-size:14px;margin:18px 0 8px;color:#cfe0f0;border-bottom:1px solid var(--line);padding-bottom:4px}
|
||
.md ul{margin:0;padding-left:18px}
|
||
.md li{margin:3px 0;color:#cbd8e6}
|
||
.md code{font-family:var(--mono);font-size:11.5px;background:#132029;padding:1px 5px;border-radius:5px;color:#9fd7c8}
|
||
.md a{color:#8fc2ff}
|
||
.pager{display:flex;gap:8px;justify-content:center;padding:14px}
|
||
.legend{font-size:11px;color:var(--dim);display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||
.hint{font-size:11.5px;color:var(--dim)}
|
||
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
|
||
@media (max-width:1100px){.g4{grid-template-columns:repeat(2,1fr)}.g21{grid-template-columns:1fr}}
|
||
@media (max-width:820px){
|
||
.app{grid-template-columns:64px 1fr}
|
||
.brand h1,.brand .ver,.nav-i span.lbl,.nav-i .k,.side-foot .health span{display:none}
|
||
.nav-i{justify-content:center}
|
||
.g4,.g3,.g2{grid-template-columns:1fr}
|
||
.content{padding:12px}
|
||
.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>
|
||
<div class="app">
|
||
<aside class="side">
|
||
<div class="brand"><div class="logo">o</div><h1>omni</h1><span class="ver">v2</span></div>
|
||
<nav id="nav"></nav>
|
||
<div class="side-foot">
|
||
<div class="health" id="health"><span class="dot"></span><span>подключение…</span></div>
|
||
<button class="btn ghost sm" data-act="help"><span class="kbd">?</span> горячие клавиши</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="main">
|
||
<header class="top">
|
||
<div class="crumb" id="crumb"><b>Обзор</b></div>
|
||
<div class="search">
|
||
<svg class="ic" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||
<input id="q" type="text" placeholder="Поиск по 41 000 записям: CVE, ключи, сливы, модели…" autocomplete="off" spellcheck="false">
|
||
<span class="kbd" style="position:absolute;right:8px;top:7px">/</span>
|
||
<div class="sug" id="sug"></div>
|
||
</div>
|
||
<div class="actions">
|
||
<select id="cat" title="Категория источников"></select>
|
||
<button class="btn primary" data-act="run" title="Собрать свежее по категории">▶ Собрать</button>
|
||
<button class="btn icon" data-act="refresh" title="Обновить (r)">⟳</button>
|
||
</div>
|
||
</header>
|
||
<main class="content" id="view"></main>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="backdrop" id="backdrop"></div>
|
||
<aside class="drawer" id="drawer">
|
||
<div class="dh"><h3 id="d-title">…</h3><button class="btn sm ghost" data-act="close-drawer" style="margin-left:auto">✕</button></div>
|
||
<div class="db" id="d-body"></div>
|
||
</aside>
|
||
<div class="toast" id="toast"></div>
|
||
<div class="modal" id="modal"><div class="box" id="modal-box"></div></div>
|
||
|
||
<script>
|
||
"use strict";
|
||
/* ============================= helpers ============================= */
|
||
const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)];
|
||
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
const num=n=>(n==null||isNaN(n))?0:Number(n).toLocaleString("ru-RU");
|
||
const k=n=>n>=1e6?(n/1e6).toFixed(1)+"M":n>=1e4?(n/1e3).toFixed(1)+"k":num(n);
|
||
const dt=s=>{if(!s)return"";s=String(s);const m=s.match(/^(\d{4})-(\d{2})-(\d{2})/);return m?`${m[3]}.${m[2]}.${m[1]}`:s.slice(0,10)};
|
||
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);}
|
||
|
||
async function api(path,opt={}){
|
||
const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opt});
|
||
if(!r.ok) throw new Error("HTTP "+r.status);
|
||
return r.json();
|
||
}
|
||
const post=(p,b)=>api(p,{method:"POST",body:JSON.stringify(b||{})});
|
||
function copy(txt){navigator.clipboard?.writeText(txt).then(()=>toast("скопировано"),()=>toast("не удалось скопировать",true));}
|
||
|
||
/* mini markdown → html */
|
||
function md(src){
|
||
const lines=String(src||"").split("\n");let out=[],inUl=false;
|
||
const inline=t=>esc(t).replace(/`([^`]+)`/g,"<code>$1</code>")
|
||
.replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>")
|
||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,a,b)=>`<a href="${b}" target="_blank" rel="noopener">${a}</a>`);
|
||
for(const ln of lines){
|
||
if(/^\s*-\s+/.test(ln)){if(!inUl){out.push("<ul>");inUl=true}out.push("<li>"+inline(ln.replace(/^\s*-\s+/,""))+"</li>");continue}
|
||
if(inUl){out.push("</ul>");inUl=false}
|
||
if(/^###\s/.test(ln))out.push("<h2>"+inline(ln.slice(4))+"</h2>");
|
||
else if(/^##\s/.test(ln))out.push("<h2>"+inline(ln.slice(3))+"</h2>");
|
||
else if(/^#\s/.test(ln))out.push("<h1>"+inline(ln.slice(2))+"</h1>");
|
||
else if(ln.trim())out.push("<p>"+inline(ln)+"</p>");
|
||
}
|
||
if(inUl)out.push("</ul>");
|
||
return out.join("\n");
|
||
}
|
||
|
||
/* ============================= state / router ============================= */
|
||
const NAV=[
|
||
{id:"overview", label:"Обзор", icon:"M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z"},
|
||
{id:"feed", label:"Лента", icon:"M4 6h16M4 12h16M4 18h10"},
|
||
{id:"abuse", label:"Абузы", icon:"M12 3l8 4v6c0 5-3.5 7.5-8 8-4.5-.5-8-3-8-8V7z"},
|
||
{id:"sources", label:"Источники", icon:"M4 6h16M4 12h16M4 18h16M4 6v12"},
|
||
{id:"runs", label:"Прогоны", icon:"M12 8v5l3 2M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z"},
|
||
{id:"tg", label:"Telegram", icon:"M21 4L3 11l5 2 2 6 3-4 5 3z"},
|
||
{id:"keys", label:"Ключи", icon:"M14 7a4 4 0 1 1-3 6.9L4 21H2v-3l8.1-6.9A4 4 0 0 1 14 7z"},
|
||
{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,src:"",
|
||
offset:0,items:[],total:0,auto:localStorage.getItem("omni.auto")==="1",
|
||
runQ:"",runF:"all",types:[],cats:[]};
|
||
|
||
function route(){
|
||
const h=location.hash.replace(/^#\/?/,"");
|
||
const [v,qs]=h.split("?");
|
||
const p=new URLSearchParams(qs||"");
|
||
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)}»`:"");
|
||
paintNav(); render();
|
||
}
|
||
function go(view,params={},replace=false){
|
||
const p=new URLSearchParams();
|
||
const q=params.q!==undefined?params.q:S.q;
|
||
if(q)p.set("q",q);
|
||
const cat=params.cat!==undefined?params.cat:S.cat; if(cat)p.set("cat",cat);
|
||
if((params.type??S.type)!=="all")p.set("type",params.type??S.type);
|
||
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();
|
||
}
|
||
function paintNav(){
|
||
$("#nav").innerHTML=NAV.map((n,i)=>`<button class="nav-i ${S.view===n.id?"on":""}" data-nav="${n.id}">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="${n.icon}"/></svg>
|
||
<span class="lbl">${n.label}</span><span class="k">${i+1}</span></button>`).join("");
|
||
}
|
||
|
||
/* ============================= chrome ============================= */
|
||
async function boot(){
|
||
paintNav();
|
||
try{
|
||
const [st,kv]=await Promise.all([api("/api/stats"),api("/api/types")]);
|
||
S.stats=st; S.types=kv.types||[];
|
||
S.cats=(st.categories||[]).map(c=>c.name);
|
||
$("#cat").innerHTML=`<option value="">Все категории · ${num(st.sources)}</option>`+
|
||
(st.categories||[]).map(c=>`<option value="${esc(c.name)}">${esc(c.name)} · ${c.total}</option>`).join("");
|
||
$("#cat").value=S.cat;
|
||
const ok=st.alive>0;
|
||
$("#health").innerHTML=`<span class="dot ${ok?"":"bad"}"></span><span>${num(st.items)} записей · ${num(st.alive)}/${num(st.sources)} живых</span>`;
|
||
$("#q").placeholder=`Поиск по ${num(st.items)} записям: CVE, ключи, сливы, модели…`;
|
||
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
|
||
route();
|
||
setInterval(refreshHealth,60000);
|
||
}
|
||
async function refreshHealth(){
|
||
try{
|
||
const h=await api("/api/health");
|
||
$("#health").innerHTML=`<span class="dot ${h.alive>0?"":"bad"}"></span><span>${num(h.items)} записей · ${num(h.alive)}/${num(h.sources)} живых · аптайм ${Math.round(h.uptime/60)} мин</span>`;
|
||
$("#q").placeholder=`Поиск по ${num(h.items)} записям: CVE, ключи, сливы, модели…`;
|
||
}catch(e){ $("#health").innerHTML=`<span class="dot bad"></span><span>API недоступен</span>`; }
|
||
}
|
||
|
||
/* ============================= views ============================= */
|
||
const V={};
|
||
|
||
/* ---------- overview ---------- */
|
||
V.overview=async()=>{
|
||
const box=$("#view");
|
||
box.innerHTML=`<div class="grid g4">${Array(4).fill(`<div class="card"><div class="skel" style="width:40%"></div><div class="skel" style="width:70%;height:26px;margin-top:10px"></div><div class="skel" style="width:55%;margin-top:10px"></div></div>`).join("")}</div>
|
||
<div class="grid g21" style="margin-top:14px"><div class="card"><div class="skel" style="height:130px"></div></div><div class="card"><div class="skel" style="height:130px"></div></div></div>`;
|
||
const [st,tl,runs,watch,ab]=await Promise.all([
|
||
api("/api/stats"), api("/api/timeline?days=30&mode=seen"), api("/api/runs?limit=6"),
|
||
api("/api/watch").catch(()=>({rules:[]})), api("/api/abuse?limit=8").catch(()=>({items:[]}))
|
||
]);
|
||
S.stats=st;
|
||
// last24 — честное скользящее окно от бэкенда; «сегодня» неполный день, процент не считаем
|
||
const day=tl.last24??0;
|
||
const live=(st.sources?(st.alive/st.sources*100):0).toFixed(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);
|
||
box.innerHTML=`
|
||
<div class="page-h"><div><h2>Обзор</h2><p>Комбайн источников: реестр, лента, абузы, ключи и Telegram-сессия в одном окне.</p></div>
|
||
<div class="right">
|
||
<button class="btn" data-act="digest-quick">Дайджест за сутки</button>
|
||
<button class="btn ${S.auto?"primary":""}" data-act="auto">${S.auto?"Автообновление вкл":"Автообновление выкл"}</button>
|
||
</div></div>
|
||
<div class="grid g4">
|
||
${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)} совпадений по правилам`)}
|
||
</div>
|
||
|
||
<div class="grid g21" style="margin-top:14px">
|
||
<div class="card"><h3>Импорт за 30 дней <span class="r">${num(tl.total)} записей · пик ${num(tl.max)}/день</span></h3>
|
||
${chart(tl.points)}
|
||
<div class="chart-x"><span>${dt(tl.points[0]?.day)}</span><span>${dt(tl.points[Math.floor(tl.points.length/2)]?.day)}</span><span>сегодня</span></div>
|
||
<div class="legend" style="margin-top:9px"><span>столбец = записей за день импорта</span><span>наведение — дата и число CVE</span><span>переключатель «сутки» в ленте фильтрует по дате публикации</span></div>
|
||
</div>
|
||
<div class="card"><h3>Источники по категориям</h3>
|
||
${(st.categories||[]).slice(0,8).map(c=>{const r=c.total?Math.round((c.alive||0)/c.total*100):0;
|
||
return `<div style="margin:7px 0">
|
||
<div class="row" style="justify-content:space-between;font-size:12px"><span>${esc(c.name)}</span>
|
||
<span class="dim mono">${c.alive||0}/${c.total} · ${num(c.stored||0)}</span></div>
|
||
<div class="bar" style="margin-top:3px"><i style="width:${r}%"></i></div></div>`}).join("")||`<div class="empty">нет данных</div>`}
|
||
</div>
|
||
</div>
|
||
|
||
<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">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 ${stPill(r.status)}" title="${esc(r.status||"нет статуса")}"></span><span class="dim">${esc(r.status||"—")}</span></td></tr>`).join("")}</tbody></table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:14px"><h3>Абузы сейчас <span class="r"><a href="#/abuse">разбор →</a></span></h3>
|
||
<div id="ov-abuse">${skel(3)}</div></div>`;
|
||
// Latest items (fast, 6)
|
||
api("/api/items?limit=6&sort=fresh").then(d=>{
|
||
$("#ov-latest").innerHTML=d.items.map(it=>itemRow(it,true)).join("")||`<div class="empty">пусто</div>`;
|
||
}).catch(()=>{$("#ov-latest").innerHTML=`<div class="empty">не удалось загрузить</div>`});
|
||
$("#ov-abuse").innerHTML=(ab.items||[]).map(it=>itemRow(it,true)).join("")||`<div class="empty">пусто</div>`;
|
||
};
|
||
|
||
function chart(pts){
|
||
const max=Math.max(1,...pts.map(p=>p.n));
|
||
return `<div class="chart">${pts.map(p=>`<div class="b" style="height:${Math.max(2,Math.round(p.n/max*96))}px">
|
||
<div class="tip">${dt(p.day)} · ${num(p.n)}${p.cve?` · CVE ${p.cve}`:""}</div></div>`).join("")}</div>`;
|
||
}
|
||
const skel=n=>Array(n).fill(`<div style="padding:9px 0"><div class="skel" style="width:70%"></div><div class="skel" style="width:45%;margin-top:6px"></div></div>`).join("");
|
||
|
||
/* ---------- feed ---------- */
|
||
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>
|
||
</div></div>
|
||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||
<span class="dim" style="font-size:11.5px">Типы:</span>
|
||
<span class="chip click ${S.type==="all"?"on":""}" data-type="all">все <span class="n">${num(S.stats?.items||0)}</span></span>
|
||
${(S.types||[]).slice(0,9).map(t=>`<span class="chip click ${S.type===t.key?"on":""}" data-type="${esc(t.key)}">${esc(t.label)} <span class="n">${k(t.n)}</span></span>`).join("")}
|
||
<span class="spacer"></span>
|
||
<select id="f-sort">${[["score","по релевантности"],["fresh","сначала новые"],["old","сначала старые"],["price","по цене"]].map(([v,l])=>`<option value="${v}" ${S.sort===v?"selected":""}>${l}</option>`).join("")}</select>
|
||
<select id="f-days">${[[0,"за всё время"],[1,"сутки"],[7,"неделя"],[30,"месяц"],[90,"квартал"]].map(([v,l])=>`<option value="${v}" ${S.days==v?"selected":""}>${l}</option>`).join("")}</select>
|
||
<label class="chk"><input type="checkbox" id="f-priced" ${S.priced?"checked":""}> только с ценой</label>
|
||
</div></div>
|
||
<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.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.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;
|
||
list.innerHTML=S.items.map(it=>itemRow(it)).join("")||`<div class="empty"><b>Ничего не найдено</b>Сбросьте фильтры или добавьте источник</div>`;
|
||
$("#more").innerHTML=S.items.length<S.total
|
||
? `<button class="btn" data-act="more">Показать ещё · осталось ${num(S.total-S.items.length)}</button>`
|
||
: `<span class="dim">показано ${num(S.items.length)} из ${num(S.total)}</span>`;
|
||
}
|
||
|
||
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 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){
|
||
const types=(it.types||"").split(",").filter(Boolean).slice(0,3);
|
||
const labels=typeLabels();
|
||
const score=it.abuse_score??it.score??0;
|
||
const cls=score>=70?"hi":score>=40?"mid":"";
|
||
const price=it.price?`<span class="chip">${esc(it.price)}${it.currency?" "+esc(it.currency):""}</span>`:"";
|
||
return `<div class="item" data-id="${it.id}">
|
||
<div>
|
||
<div class="t">${esc(it.title||"(без заголовка)")}</div>
|
||
<div class="m"><span class="src">${esc(it.source||"")}</span>
|
||
${types.map(t=>`<span class="chip t ${t}">${esc(labels[t]||t)}</span>`).join("")}
|
||
${price}${findChips(it.find)}<span>${dt(it.published)}</span>${it.views?`<span>👁 ${num(it.views)}</span>`:""}</div>
|
||
${compact?"":`<div class="x">${esc(it.text||"")}</div>`}
|
||
</div>
|
||
<div class="side">
|
||
<span class="sc ${cls}">${score?Math.round(score):"—"}</span>
|
||
<div class="row" style="gap:2px">
|
||
<button class="ico" data-open="${esc(it.url||"")}" title="открыть">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 4h6v6M20 4l-8 8M9 5H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-4"/></svg></button>
|
||
<button class="ico" data-copy="${esc(it.url||it.title||"")}" title="копировать ссылку">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
|
||
</div>
|
||
</div></div>`;
|
||
}
|
||
const typeLabels=()=>Object.fromEntries((S.types||[]).map(t=>[t.key,t.label]));
|
||
|
||
async function openItem(id){
|
||
const dr=$("#drawer"), bd=$("#backdrop");
|
||
dr.classList.add("on"); bd.classList.add("on");
|
||
$("#d-title").textContent="Загрузка…";
|
||
$("#d-body").innerHTML=`<div class="skel" style="width:60%"></div><div class="skel" style="width:90%;margin-top:10px"></div><div class="skel" style="width:80%;margin-top:8px"></div>`;
|
||
try{
|
||
const d=await api("/api/item/"+id); const it=d.item;
|
||
$("#d-title").textContent=it.title||"(без заголовка)";
|
||
const rel=r=>r.map(x=>`<a href="#" data-id="${x.id}">${esc(x.title)} <span class="dim">· ${esc(x.source)}</span></a>`).join("");
|
||
$("#d-body").innerHTML=`
|
||
<div class="row"><span class="chip">${esc(it.source)}</span>
|
||
${(it.types||[]).map(t=>`<span class="chip t ${esc(t)}">${esc(t)}</span>`).join("")}
|
||
<span class="dim mono">${dt(it.published)}</span>
|
||
${it.price?`<span class="chip on">${esc(it.price)} ${esc(it.currency||"")}</span>`:""}</div>
|
||
<div class="row">
|
||
<a class="btn primary" href="${esc(it.url)}" target="_blank" rel="noopener">Открыть оригинал ↗</a>
|
||
<button class="btn" data-copy="${esc(it.url)}">Копировать ссылку</button>
|
||
<button class="btn" data-watch="${esc((it.title||"").slice(0,40))}">☆ В watchlist</button>
|
||
</div>
|
||
<div class="txt">${esc(it.text||"текста нет")}</div>
|
||
${it.related_same_source?.length?`<div><div class="sec-h">ещё из ${esc(it.source)}</div><div class="rel">${rel(it.related_same_source)}</div></div>`:""}
|
||
${it.related_same_host?.length?`<div><div class="sec-h">с того же домена</div><div class="rel">${rel(it.related_same_host)}</div></div>`:""}
|
||
<div class="kv"><div class="k">id</div><div class="mono">${it.id}</div>
|
||
<div class="k">провайдер / вид</div><div>${esc(it.provider)} / ${esc(it.kind)}</div>
|
||
<div class="k">автор</div><div>${esc(it.author||"—")}</div>
|
||
<div class="k">score</div><div class="mono">${it.score??"—"}</div></div>`;
|
||
}catch(e){ $("#d-body").innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
|
||
}
|
||
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);
|
||
$("#view").innerHTML=`
|
||
<div class="page-h"><div><h2>Абузы</h2><p>Скам/промо/триалы/ключи в постах каналов и форумов. Вес считается по признакам: обещания, цены, «в личку», свежесть.</p></div>
|
||
<div class="right"><button class="btn" data-act="summary">ИИ-саммари</button></div></div>
|
||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||
<span class="chip click on" data-tag="">все <span class="n">${num(d.total)}</span></span>
|
||
${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>`;
|
||
S.abuse=d.items||[];
|
||
}
|
||
async function summary(tag){
|
||
const box=$("#sum-box"); box.innerHTML=`<div class="card" style="margin-top:14px"><div class="skel" style="width:40%"></div><div class="skel" style="width:90%;margin-top:10px"></div></div>`;
|
||
try{
|
||
const d=await api("/api/summary?limit=14"+(tag?"&tag="+encodeURIComponent(tag):""));
|
||
box.innerHTML=`<div class="card" style="margin-top:14px"><h3>ИИ-саммари <span class="r">${d.n||0} записей</span></h3>
|
||
<div class="md">${md(d.summary||"")}</div></div>`;
|
||
}catch(e){ box.innerHTML=`<div class="card" style="margin-top:14px"><div class="empty">ИИ не настроен: ${esc(e.message)}</div></div>`; }
|
||
}
|
||
|
||
/* ---------- sources ---------- */
|
||
V.sources=async()=>{
|
||
const box=$("#view");
|
||
box.innerHTML=`<div class="page-h"><div><h2>Источники</h2><p>Реестр площадок: адаптер, категория, живость, сколько записей реально лежит в базе.</p></div>
|
||
<div class="right"><button class="btn" data-act="add-src">+ Добавить</button><button class="btn" data-act="check-all">Проверить выбранные</button></div></div>
|
||
<div id="src-tiles" class="grid g4" style="margin-bottom:12px"></div>
|
||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||
<input id="s-q" type="text" placeholder="фильтр по имени или url" style="min-width:260px" value="${esc(S.srcQ||"")}">
|
||
<select id="s-cat"><option value="">все категории</option></select>
|
||
<select id="s-state">
|
||
<option value="">любое состояние</option><option value="alive">живые</option>
|
||
<option value="dead">мёртвые</option><option value="untested">не проверялись</option><option value="off">выключены</option>
|
||
</select>
|
||
<span class="spacer"></span>
|
||
<button class="btn sm" data-act="toggle-all" data-on="1">включить все</button>
|
||
<button class="btn sm" data-act="toggle-all" data-on="0">выключить все</button>
|
||
</div></div>
|
||
<div id="src-bulk" class="card" style="display:none;padding:8px 12px;margin-bottom:10px"><div class="row">
|
||
<span id="src-sel" class="dim">выбрано 0</span><span class="spacer"></span>
|
||
<button class="btn sm" data-act="bulk-on">включить</button><button class="btn sm" data-act="bulk-off">выключить</button>
|
||
<button class="btn sm" data-act="bulk-check">проверить</button></div></div>
|
||
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="src-tbl"><thead><tr>
|
||
<th style="width:26px"><input type="checkbox" id="src-all"></th><th>источник</th><th>категория</th>
|
||
<th>адаптер</th><th class="right">проверка</th><th class="right">в базе</th><th>состояние</th></tr></thead>
|
||
<tbody><tr><td colspan="7">${skel(4)}</td></tr></tbody></table></div></div>`;
|
||
try{
|
||
const st=S.stats||await api("/api/stats");
|
||
$("#src-tiles").innerHTML=[
|
||
["всего",st.sources,""],["живых",st.alive,`${Math.round(st.alive/st.sources*100)}%`],
|
||
["проверено",st.tested,`${Math.round((st.tested||0)/st.sources*100)}%`],["выключено",st.sources-st.sources_enabled,""]
|
||
].map(([l,v,s])=>`<div class="card"><div class="sub">${l}</div><div class="hl"><span class="n">${num(v)}</span> <span class="l">${s||""}</span></div></div>`).join("");
|
||
$("#s-cat").innerHTML=`<option value="">все категории</option>`+(st.categories||[]).map(c=>`<option>${esc(c.name)}</option>`).join("");
|
||
}catch(e){}
|
||
await loadSources();
|
||
};
|
||
async function loadSources(){
|
||
const p=new URLSearchParams({category:S.srcCat||"",q:S.srcQ||"",state:S.srcState||""});
|
||
const d=await api("/api/sources?"+p);
|
||
const tb=$("#src-tbl tbody"); if(!tb)return;
|
||
tb.innerHTML=(d.items||[]).slice(0,900).map(s=>{
|
||
const alive=(s.items||0)>0, state=s.enabled?(s.checked?(alive?"живой":"мёртвый"):"не проверен"):"выключен";
|
||
const pc=alive?"ok":(s.checked?"no":"unk");
|
||
return `<tr data-src="${esc(s.name)}">
|
||
<td><input type="checkbox" class="src-ck" data-name="${esc(s.name)}"></td>
|
||
<td><span class="nm">${esc(s.name)}</span><span class="u">${esc(s.url)}</span></td>
|
||
<td><span class="chip">${esc(s.category)}</span></td>
|
||
<td class="mono dim">${esc(s.adapter)}</td>
|
||
<td class="right mono">${s.items??"—"}</td>
|
||
<td class="right mono">${num(s.stored||0)}</td>
|
||
<td class="nowrap"><span class="pill ${pc}"></span>${state}${s.note?` <span class="dim">· ${esc(s.note)}</span>`:""}</td>
|
||
</tr>`}).join("")||`<tr><td colspan="7"><div class="empty">ничего не найдено</div></td></tr>`;
|
||
const hidden=(d.items||[]).length>900?`<div class="hint" style="padding:8px 10px">показаны первые 900 из ${num(d.items.length)} — уточните фильтр</div>`:"";
|
||
tb.insertAdjacentHTML("beforeend",`<tr><td colspan="7" style="border:0">${hidden}</td></tr>`);
|
||
}
|
||
async function srcDetail(name){
|
||
closeDrawer(); const dr=$("#drawer"),bd=$("#backdrop"); dr.classList.add("on");bd.classList.add("on");
|
||
$("#d-title").textContent=name; $("#d-body").innerHTML=`<div class="skel" style="width:50%"></div>`;
|
||
try{
|
||
const d=await api("/api/source?name="+encodeURIComponent(name));
|
||
if(d.error) throw new Error(d.error);
|
||
const s=d.source;
|
||
$("#d-body").innerHTML=`
|
||
<div class="kv"><div class="k">категория</div><div>${esc(s.category)}</div>
|
||
<div class="k">адаптер</div><div class="mono">${esc(s.adapter)}</div>
|
||
<div class="k">url</div><div><a href="${esc(s.url)}" target="_blank" rel="noopener">${esc(s.url)}</a></div>
|
||
<div class="k">теги</div><div>${(s.tags||[]).map(t=>`<span class="chip">${esc(t)}</span>`).join(" ")||"—"}</div>
|
||
<div class="k">в базе</div><div class="mono">${num(d.stored)}</div>
|
||
<div class="k">состояние</div><div>${d.state?.checked?(d.state.items>0?"живой":"мёртвый"):"не проверен"} ${d.state?.enabled===false?"· выключен":""}</div></div>
|
||
<div class="row"><button class="btn" data-toggle-src="${esc(name)}" data-on="${d.state?.enabled===false?1:0}">${d.state?.enabled===false?"Включить":"Выключить"}</button>
|
||
<button class="btn" data-check-src="${esc(name)}">Проверить</button>
|
||
<button class="btn" data-act="feed-src" data-src="${esc(name)}">Собрать этот источник</button></div>
|
||
<div><div class="sec-h">последние записи</div><div class="rel">
|
||
${(d.latest||[]).map(x=>`<a href="#" data-id="${x.id}">${esc(x.title)} <span class="dim">· ${dt(x.published)}</span></a>`).join("")||`<span class="dim">нет</span>`}</div></div>
|
||
<div><div class="sec-h">прогоны</div><table><tbody>${(d.runs||[]).map(r=>`<tr><td class="dim mono">${esc(r.when)}</td><td class="mono right">${r.got}/${r.kept}</td><td class="right">${esc(r.status||"")}</td></tr>`).join("")||`<tr><td class="dim">нет</td></tr>`}</tbody></table></div>`;
|
||
}catch(e){ $("#d-body").innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
/* ---------- runs ---------- */
|
||
V.runs=async()=>{
|
||
const d=await api("/api/runs?limit=200");
|
||
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">
|
||
${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" 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>${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 ${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===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 ---------- */
|
||
V.tg=async()=>{
|
||
const [st,recent]=await Promise.all([api("/api/tg/status"), api("/api/items?limit=12&sort=fresh")]);
|
||
$("#view").innerHTML=`
|
||
<div class="page-h"><div><h2>Telegram</h2><p>Своя сессия Telethon: глубина истории и каналы без веб-превью. Веб-превью остаётся для публичных каналов.</p></div></div>
|
||
<div class="grid g3">
|
||
<div class="card"><h3>Сессия</h3>
|
||
<div class="row"><span class="pill ${st.enabled?"ok":"no"}"></span><b>${st.enabled?"подключена":"не настроена"}</b></div>
|
||
<div class="kv" style="margin-top:10px">
|
||
<div class="k">аккаунт</div><div>${esc(st.me?.first_name||"—")} ${st.me?.username?"@"+esc(st.me.username):""}</div>
|
||
<div class="k">id</div><div class="mono">${st.me?.id||"—"}</div>
|
||
<div class="k">session</div><div class="mono" style="font-size:11px">${esc(st.session||"—")}</div>
|
||
<div class="k">глубина</div><div class="mono">${st.limit} постов</div>
|
||
</div>
|
||
${st.error?`<div class="hint" style="color:var(--bad);margin-top:8px">${esc(st.error)}</div>`:""}
|
||
</div>
|
||
<div class="card"><h3>Найти каналы</h3>
|
||
<div class="row"><input id="tg-q" placeholder="например: приватки, скам, ai" style="flex:1">
|
||
<button class="btn primary" data-act="tg-search">Искать</button></div>
|
||
<div id="tg-res" class="hint" style="margin-top:10px">Глобальный поиск по каналам и группам через сессию.</div>
|
||
</div>
|
||
<div class="card"><h3>Тянуть посты</h3>
|
||
<div class="field"><label>каналы через запятую (@name или ссылка)</label>
|
||
<textarea id="tg-hands" placeholder="durov, @alstack"></textarea></div>
|
||
<div class="row" style="margin-top:8px"><div class="field"><label>постов на канал</label>
|
||
<input id="tg-lim" type="number" value="100" min="5" max="500" style="width:90px"></div>
|
||
<button class="btn primary" data-act="tg-pull" style="align-self:flex-end">Тянуть</button></div>
|
||
<div id="tg-job" class="hint" style="margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-top:14px"><h3>Последние записи из Telegram <span class="r">${st.enabled?"сессия + веб-превью":"веб-превью"}</span></h3>
|
||
<div id="tg-recent">${(recent.items||[]).map(it=>itemRow(it,true)).join("")||`<div class="empty">нет</div>`}</div></div>`;
|
||
};
|
||
async function tgSearch(){
|
||
const q=$("#tg-q").value.trim(); if(q.length<2){toast("минимум 2 символа",true);return}
|
||
const box=$("#tg-res"); box.innerHTML="ищу…";
|
||
try{
|
||
const d=await api("/api/tg/search?q="+encodeURIComponent(q)+"&limit=30");
|
||
if(!d.ok) throw new Error(d.error||"ошибка");
|
||
box.innerHTML=`<table><tbody>${(d.channels||[]).map(c=>`<tr><td><b>${esc(c.title)}</b><span class="u">@${esc(c.username)}</span></td>
|
||
<td class="right mono dim">${k(c.participants||0)}</td>
|
||
<td class="right"><button class="btn sm" data-pull-one="${esc(c.username)}">тянуть</button></td></tr>`).join("")||`<tr><td class="dim">ничего</td></tr>`}</tbody></table>`;
|
||
}catch(e){ box.innerHTML=`<span style="color:var(--bad)">${esc(e.message)}</span>`; }
|
||
}
|
||
async function tgPull(handles){
|
||
const lim=+($("#tg-lim")?.value||100);
|
||
const box=$("#tg-job"); box.textContent="задача запущена…";
|
||
try{
|
||
const {job}=await post("/api/tg/pull",{handles,limit:lim});
|
||
const t=setInterval(async()=>{
|
||
const st=await api("/api/job/"+job);
|
||
if(st.state==="done"||st.state==="error"){
|
||
clearInterval(t);
|
||
box.innerHTML=st.state==="error"?`<span style="color:var(--bad)">${esc(st.error||"ошибка")}</span>`
|
||
:`готово: каналов ${st.channels}, постов ${st.fetched}, новых ${st.new}, дублей ${st.dup}`;
|
||
if(st.errors?.length) box.innerHTML+=`<div class="dim">ошибки: ${st.errors.map(e=>esc(e.handle+": "+e.error)).join(", ")}</div>`;
|
||
toast("Telegram: +"+ (st.new||0) +" записей");
|
||
} else box.textContent="тяну…";
|
||
},1200);
|
||
}catch(e){ box.textContent="ошибка: "+e.message; }
|
||
}
|
||
|
||
/* ---------- keys ---------- */
|
||
V.keys=async()=>{
|
||
const d=await api("/api/keys");
|
||
const rows=(d.services||[]).sort((a,b)=>b.keys-a.keys);
|
||
const max=Math.max(...rows.map(r=>r.keys),1);
|
||
$("#view").innerHTML=`
|
||
<div class="page-h"><div><h2>Ключи</h2><p>Vault вне git: пул на сервис, ротация round-robin, остывание после 401/402/403 (час) и 429 (10 минут).</p></div>
|
||
<div class="right"><span class="chip on">готово ${num(d.total_ready)}</span><span class="chip">всего ${num(d.total_keys)}</span></div></div>
|
||
<div class="card" style="padding:10px 12px;margin-bottom:12px"><div class="row">
|
||
<input id="k-q" placeholder="фильтр по сервису" style="min-width:240px"><span class="spacer"></span>
|
||
<span class="dim hint">значения не отдаются в UI — только маска</span></div></div>
|
||
<div class="card" style="padding:0"><div class="tbl-wrap"><table id="k-tbl"><thead><tr>
|
||
<th>сервис</th><th>ключей</th><th style="width:30%">распределение</th><th class="right">готово</th><th class="right">остывают</th><th>источник</th></tr></thead>
|
||
<tbody>${rows.map(r=>`<tr><td class="nm">${esc(r.service)}</td><td class="mono">${r.keys}</td>
|
||
<td><div class="bar"><i style="width:${Math.round(r.keys/max*100)}%;background:${r.ready?"var(--acc)":"var(--bad)"}"></i></div></td>
|
||
<td class="right mono">${r.ready}</td><td class="right mono ${r.cooling?"down":""}">${r.cooling||""}</td>
|
||
<td class="dim">${r.env?"env (override)":"vault.json"}</td></tr>`).join("")}</tbody></table></div></div>
|
||
<div class="hint" style="margin-top:10px">Проверка ключа = реальный вызов API из парсера; неудачный ответ отправляет ключ в остывание автоматически. <span class="mono">omni keys</span> — CLI-срез.</div>`;
|
||
$("#k-q").addEventListener("input",debounce(e=>{
|
||
const v=e.target.value.toLowerCase();
|
||
$$("#k-tbl tbody tr").forEach(tr=>tr.style.display=tr.textContent.toLowerCase().includes(v)?"":"none");
|
||
},120));
|
||
};
|
||
|
||
/* ---------- digest ---------- */
|
||
V.digest=async()=>{
|
||
$("#view").innerHTML=`
|
||
<div class="page-h"><div><h2>Дайджест</h2><p>Markdown-сводка за период: главное по score, уязвимости, сливы, абузы. Готовый файл — data/digest_latest.md.</p></div>
|
||
<div class="right"><select id="d-days"><option value="1">сутки</option><option value="3" selected>3 дня</option><option value="7">неделя</option></select>
|
||
<button class="btn primary" data-act="digest">Собрать</button>
|
||
<a class="btn" id="d-dl" href="/api/digest?days=3&download=1">Скачать .md</a></div></div>
|
||
<div class="card"><div id="d-out" class="md"><div class="empty">Нажмите «Собрать» — займёт пару секунд.</div></div></div>`;
|
||
};
|
||
async function runDigest(){
|
||
const days=$("#d-days").value; const out=$("#d-out");
|
||
const dl=$("#d-dl"); if(dl) dl.href=`/api/digest?days=${days}&download=1`;
|
||
out.innerHTML=`<div class="skel" style="width:40%"></div><div class="skel" style="width:90%;margin-top:10px"></div><div class="skel" style="width:75%;margin-top:8px"></div>`;
|
||
try{ const d=await api("/api/digest?days="+days); out.innerHTML=md(d.md);
|
||
out.insertAdjacentHTML("afterbegin",`<div class="row" style="margin-bottom:10px"><button class="btn sm" data-copy-md>Копировать markdown</button><span class="dim">${num(d.n)} записей за период</span></div>`);
|
||
out.dataset.md=d.md;
|
||
}catch(e){ out.innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
/* ---------- watchlist ---------- */
|
||
V.watch=async()=>{
|
||
const d=await api("/api/watch");
|
||
$("#view").innerHTML=`
|
||
<div class="page-h"><div><h2>Watchlist</h2><p>Правила по подстроке и типу: панель считает совпадения с момента добавления и показывает свежие хиты.</p></div></div>
|
||
<div class="card" style="margin-bottom:14px"><div class="row">
|
||
<input id="w-q" placeholder="например: CVE-2026, firecrawl, приватка" style="min-width:320px">
|
||
<select id="w-type"><option value="all">любой тип</option>${(S.types||[]).map(t=>`<option value="${esc(t.key)}">${esc(t.label)}</option>`).join("")}</select>
|
||
<button class="btn primary" data-act="watch-add">Добавить правило</button></div>
|
||
<div class="row" style="margin-top:10px"><span class="dim">быстро:</span>
|
||
${["CVE-2026","ключ","firecrawl","слив","промо","52pojie"].map(x=>`<span class="chip click" data-watch-q="${esc(x)}">${esc(x)}</span>`).join("")}</div></div>
|
||
<div class="grid g2">${(d.rules||[]).map(r=>`<div class="card hov">
|
||
<div class="row"><b>${esc(r.q)}</b><span class="chip">${esc(r.type)}</span><span class="spacer"></span>
|
||
<span class="chip on">${num(r.count)}</span><button class="ico" data-watch-del="${esc(r.id)}">✕</button></div>
|
||
<div class="rel" style="margin-top:8px">${(r.latest||[]).map(x=>`<a href="#" data-id="${x.id}">${esc(x.title)} <span class="dim">· ${esc(x.source||"")}</span></a>`).join("")||`<span class="dim">совпадений пока нет</span>`}</div>
|
||
</div>`).join("")||`<div class="card"><div class="empty"><b>Правил нет</b>Добавьте первое — например «CVE-2026» или «ключ»</div></div>`}</div>`;
|
||
$("#w-q").addEventListener("keydown",e=>{if(e.key==="Enter")$("[data-act=watch-add]").click()});
|
||
};
|
||
|
||
/* ============================= actions ============================= */
|
||
const ACT={
|
||
help:()=>{
|
||
$("#modal").classList.add("on");
|
||
$("#modal-box").innerHTML=`<h3>Горячие клавиши</h3><div class="kv">
|
||
<div class="k"><span class="kbd">/</span></div><div>фокус в поиск</div>
|
||
<div class="k"><span class="kbd">1…9</span></div><div>разделы: обзор, лента, абузы, источники, прогоны, telegram, ключи, дайджест, watchlist</div>
|
||
<div class="k"><span class="kbd">Enter</span></div><div>открыть запись (в ленте — первая)</div>
|
||
<div class="k"><span class="kbd">Esc</span></div><div>закрыть панель/модалку</div>
|
||
<div class="k"><span class="kbd">r</span></div><div>перезагрузить данные раздела</div>
|
||
<div class="k"><span class="kbd">?</span></div><div>эта справка</div></div>
|
||
<div class="hint" style="margin-top:12px">Фильтры живут в URL: ссылку можно кидать в чат и открывать в новой вкладке.</div>
|
||
<div class="row" style="margin-top:14px"><button class="btn" data-act="close-modal">Понятно</button></div>`;
|
||
},
|
||
"close-modal":()=>$("#modal").classList.remove("on"),
|
||
"close-drawer":closeDrawer,
|
||
refresh:async()=>{toast("обновляю");await render()},
|
||
auto:()=>{S.auto=!S.auto;localStorage.setItem("omni.auto",S.auto?"1":"0");toast(S.auto?"автообновление раз в минуту":"автообновление выключено");render()},
|
||
run:async()=>{
|
||
const cat=$("#cat").value||null;
|
||
const {job}=await post("/api/feed",{category:cat,limit:12});
|
||
toast("сбор запущен: "+(cat||"все категории"));
|
||
const t=setInterval(async()=>{
|
||
const st=await api("/api/job/"+job);
|
||
if(st.state==="done"||st.state==="error"){clearInterval(t);
|
||
toast(st.state==="error"?"сбор упал: "+st.error:`сбор: +${st.new} новых, ${st.fetched} получено`,st.state==="error");
|
||
render();}
|
||
},1500);
|
||
},
|
||
more:()=>loadItems(false),
|
||
"digest-quick":()=>{go("digest",{},true);setTimeout(()=>{const b=$("[data-act=digest]");b&&b.click()},150)},
|
||
digest:runDigest,
|
||
summary:()=>summary(),
|
||
"add-src":()=>{
|
||
$("#modal").classList.add("on");
|
||
$("#modal-box").innerHTML=`<h3>Новый источник</h3><div class="grid g2">
|
||
<div class="field"><label>имя</label><input id="a-name" placeholder="example-forum"></div>
|
||
<div class="field"><label>категория</label><input id="a-cat" value="community" list="cats-list"><datalist id="cats-list">${(S.cats||[]).map(c=>`<option>${esc(c)}</option>`).join("")}</datalist></div>
|
||
<div class="field" style="grid-column:1/-1"><label>url</label><input id="a-url" placeholder="https://example.com/rss"></div>
|
||
<div class="field"><label>адаптер</label><select id="a-adapter">${["rss","html_listing","json","tg_web","xenforo_latest","discourse","sitemap_recent"].map(a=>`<option>${a}</option>`).join("")}</select></div>
|
||
<div class="field"><label>лимит</label><input id="a-lim" type="number" value="20"></div>
|
||
<div class="field" style="grid-column:1/-1"><label>теги (через запятую)</label><input id="a-tags"></div></div>
|
||
<div class="row" style="margin-top:14px"><button class="btn primary" data-act="add-src-save">Добавить</button>
|
||
<button class="btn" data-act="close-modal">Отмена</button></div>`;
|
||
},
|
||
"add-src-save":async()=>{
|
||
const body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",
|
||
url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:+$("#a-lim").value||20,
|
||
tags:$("#a-tags").value.split(",").map(x=>x.trim()).filter(Boolean)};
|
||
if(!body.name||!body.url){toast("нужны имя и url",true);return}
|
||
try{ await post("/api/sources/add",body); toast("источник добавлен"); $("#modal").classList.remove("on"); render(); }
|
||
catch(e){ toast("ошибка: "+e.message,true) }
|
||
},
|
||
"toggle-all":async(el)=>{
|
||
const on=el.dataset.on==="1";
|
||
if(!confirm((on?"Включить":"Выключить")+" все источники"+(S.srcCat?" категории "+S.srcCat:"")))return;
|
||
const d=await post("/api/sources/toggle_all",{enabled:on,category:S.srcCat||null});
|
||
toast(`${on?"включено":"выключено"}: ${d.changed}`); render();
|
||
},
|
||
"bulk-on":()=>bulkToggle(true),"bulk-off":()=>bulkToggle(false),
|
||
"bulk-check":async()=>{
|
||
const names=selectedSrc(); if(!names.length){toast("ничего не выбрано",true);return}
|
||
const {job}=await post("/api/sources/check",{names});
|
||
toast("проверка запущена: "+names.length);
|
||
const t=setInterval(async()=>{const st=await api("/api/job/"+job);
|
||
if(st.state==="done"){clearInterval(t);toast(`проверено ${st.done}: живых ${st.alive}, мёртвых ${st.dead}`);render()}},1200);
|
||
},
|
||
"check-all":async()=>{ const {job}=await post("/api/sources/check",{category:S.srcCat||null});
|
||
toast("проверка категории запущена");
|
||
const t=setInterval(async()=>{const st=await api("/api/job/"+job);
|
||
if(st.state==="done"){clearInterval(t);toast(`проверено ${st.done}: живых ${st.alive}`);render()}},1500); },
|
||
"tg-search":tgSearch,
|
||
"tg-pull":()=>{
|
||
const raw=$("#tg-hands").value.split(/[,\s]+/).map(x=>x.trim()).filter(Boolean);
|
||
if(!raw.length){toast("укажите каналы",true);return}
|
||
tgPull(raw);
|
||
},
|
||
"feed-src":async(el)=>{ const {job}=await post("/api/feed",{category:null,limit:12,tags:el.dataset.src});
|
||
toast("сбор источника"); const t=setInterval(async()=>{const st=await api("/api/job/"+job);
|
||
if(st.state==="done"){clearInterval(t);toast(`+${st.new} новых`);render()}},1500); },
|
||
"watch-add":async()=>{
|
||
const q=$("#w-q").value.trim(),type=$("#w-type").value;
|
||
if(q.length<2){toast("минимум 2 символа",true);return}
|
||
await post("/api/watch/add",{q,type}); toast("правило добавлено"); render();
|
||
}
|
||
};
|
||
async function bulkToggle(on){
|
||
const names=selectedSrc(); if(!names.length){toast("ничего не выбрано",true);return}
|
||
for(const n of names) await post("/api/sources/toggle",{name:n,enabled:on});
|
||
toast(`${on?"включено":"выключено"}: ${names.length}`); render();
|
||
}
|
||
const selectedSrc=()=>$$(".src-ck:checked").map(c=>c.dataset.name);
|
||
|
||
/* ============================= events ============================= */
|
||
document.addEventListener("click",async e=>{
|
||
const t=e.target;
|
||
const nav=t.closest("[data-nav]"); if(nav){go(nav.dataset.nav,{},{replace:true});return}
|
||
const act=t.closest("[data-act]"); if(act){ const f=ACT[act.dataset.act]; if(f){e.preventDefault();await f(act);} return }
|
||
if(t.closest("[data-close-modal]")) return;
|
||
const open=t.closest("[data-open]"); if(open){e.preventDefault();window.open(open.dataset.open,"_blank","noopener");return}
|
||
const cp=t.closest("[data-copy]"); if(cp){e.preventDefault();copy(cp.dataset.copy);return}
|
||
const cm=t.closest("[data-copy-md]"); if(cm){e.preventDefault();copy($("#d-out").dataset.md||"");return}
|
||
const ty=t.closest("[data-type]"); if(ty){e.preventDefault();go("feed",{type:ty.dataset.type},{replace:true});return}
|
||
const tg=t.closest("[data-tag]"); if(tg){e.preventDefault();$$("[data-tag]").forEach(x=>x.classList.remove("on"));tg.classList.add("on");abuseByTag(tg.dataset.tag);return}
|
||
const it=t.closest("[data-id]"); if(it){e.preventDefault();openItem(+it.dataset.id);return}
|
||
const wd=t.closest("[data-watch-del]"); if(wd){e.preventDefault();await post("/api/watch/remove",{id:wd.dataset.watchDel});toast("правило удалено");render();return}
|
||
const wt=t.closest("[data-watch]"); if(wt){e.preventDefault();await post("/api/watch/add",{q:wt.dataset.watch,type:"all"});toast("в watchlist");return}
|
||
const wq=t.closest("[data-watch-q]"); if(wq){e.preventDefault();
|
||
post("/api/watch/add",{q:wq.dataset.watchQ,type:"all"}).then(()=>{toast("правило добавлено: "+wq.dataset.watchQ);render()});return}
|
||
const po=t.closest("[data-pull-one]"); if(po){e.preventDefault();tgPull([po.dataset.pullOne]);return}
|
||
const ts=t.closest("[data-toggle-src]"); if(ts){e.preventDefault();
|
||
await post("/api/sources/toggle",{name:ts.dataset.toggleSrc,enabled:ts.dataset.on==="1"});toast("переключено");srcDetail(ts.dataset.toggleSrc);return}
|
||
const cs=t.closest("[data-check-src]"); if(cs){e.preventDefault();
|
||
const {job}=await post("/api/sources/check",{names:[cs.dataset.checkSrc]});toast("проверяю источник");
|
||
const tm=setInterval(async()=>{const st=await api("/api/job/"+job);if(st.state==="done"){clearInterval(tm);srcDetail(cs.dataset.checkSrc)}},1000);return}
|
||
const row=t.closest("tr[data-src]"); if(row && !t.closest("input")){e.preventDefault();srcDetail(row.dataset.src);return}
|
||
if(t.closest("#backdrop")||t.id==="modal"){ closeDrawer(); $("#modal").classList.remove("on"); }
|
||
});
|
||
document.addEventListener("change",async e=>{
|
||
const t=e.target;
|
||
if(t.id==="cat"){S.cat=t.value;go(S.view,{cat:t.value},{replace:true});return}
|
||
if(t.id==="f-sort"){go("feed",{sort:t.value},{replace:true});return}
|
||
if(t.id==="f-days"){go("feed",{days:+t.value},{replace:true});return}
|
||
if(t.id==="f-priced"){go("feed",{priced:t.checked},{replace:true});return}
|
||
if(t.id==="s-cat"){S.srcCat=t.value;loadSources();return}
|
||
if(t.id==="s-state"){S.srcState=t.value;loadSources();return}
|
||
if(t.id==="src-all"){ $$(".src-ck").forEach(c=>c.checked=t.checked); srcBulkBar(); return }
|
||
if(t.classList?.contains("src-ck")){ srcBulkBar(); return }
|
||
});
|
||
function srcBulkBar(){
|
||
const n=selectedSrc().length; const bar=$("#src-bulk");
|
||
if(!bar)return; bar.style.display=n?"":"none"; const s=$("#src-sel"); if(s)s.textContent="выбрано "+n;
|
||
}
|
||
document.addEventListener("keydown",e=>{
|
||
const inField=/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName||"");
|
||
if(e.key==="Escape"){closeDrawer();$("#modal").classList.remove("on");$("#sug").classList.remove("on");return}
|
||
if(e.key==="/"&&!inField){e.preventDefault();$("#q").focus();return}
|
||
if(e.key==="?"&&!inField){e.preventDefault();ACT.help();return}
|
||
if(e.key==="r"&&!inField){e.preventDefault();render();return}
|
||
if(/^[1-9]$/.test(e.key)&&!inField){const n=NAV[+e.key-1];if(n)go(n.id,{},{replace:true});return}
|
||
if(e.key==="Enter"&&!inField&&S.view==="feed"&&S.items[0]){openItem(S.items[0].id)}
|
||
});
|
||
|
||
/* search + suggestions */
|
||
const qEl=$("#q");
|
||
qEl.addEventListener("input",debounce(async e=>{
|
||
const v=e.target.value.trim();
|
||
if(v.length<2){$("#sug").classList.remove("on");return}
|
||
try{
|
||
const d=await api("/api/suggest?q="+encodeURIComponent(v));
|
||
if(!d.titles?.length&&!d.sources?.length){$("#sug").classList.remove("on");return}
|
||
$("#sug").innerHTML=
|
||
(d.titles?.length?`<div class="grp">записи</div>`+d.titles.map(x=>`<div class="it" data-sug-id="${x.id}">${esc(x.title)}<span class="s">${esc(x.source)}</span></div>`).join(""):"")+
|
||
(d.sources?.length?`<div class="grp">источники</div>`+d.sources.map(x=>`<div class="it" data-sug-src="${esc(x.name)}">${esc(x.name)}<span class="s">${esc(x.category)}</span></div>`).join(""):"")+
|
||
`<div class="grp">поиск</div><div class="it" data-sug-q="${esc(v)}">Искать «${esc(v)}» во всех записях<span class="s">Enter</span></div>`;
|
||
$("#sug").classList.add("on");
|
||
}catch(e){}
|
||
},200));
|
||
qEl.addEventListener("keydown",e=>{
|
||
if(e.key==="Enter"){
|
||
const v=qEl.value.trim(); $("#sug").classList.remove("on");
|
||
if(S.view==="feed") go("feed",{q:v},{replace:true}); else go("feed",{q:v});
|
||
}
|
||
});
|
||
$("#sug").addEventListener("click",e=>{
|
||
const a=e.target.closest("[data-sug-id]"), s=e.target.closest("[data-sug-src]"), q=e.target.closest("[data-sug-q]");
|
||
$("#sug").classList.remove("on");
|
||
if(a) return openItem(+a.dataset.sugId);
|
||
if(s){ S.srcQ=s.dataset.sugSrc; if(S.view!=="sources") go("sources"); else render(); return }
|
||
if(q){ qEl.value=q.dataset.sugQ; go("feed",{q:q.dataset.sugQ},{replace:S.view==="feed"}) }
|
||
});
|
||
document.addEventListener("click",e=>{ if(!e.target.closest(".search")) $("#sug").classList.remove("on") });
|
||
|
||
/* ============================= render loop ============================= */
|
||
let AUTO_T;
|
||
async function render(){
|
||
const v=NAV.find(n=>n.id===S.view); if(!v)return;
|
||
paintNav();
|
||
$("#crumb").innerHTML="<b>"+esc(v.label)+"</b>"+(S.q?` · «${esc(S.q)}»`:"");
|
||
try{ await V[v.id](); }catch(e){ $("#view").innerHTML=`<div class="card"><div class="empty"><b>Ошибка раздела</b>${esc(e.message)}</div></div>`; }
|
||
if($("#cat")) $("#cat").value=S.cat;
|
||
clearInterval(AUTO_T);
|
||
if(S.auto) AUTO_T=setInterval(()=>{ if(document.visibilityState==="visible"&&!$("#drawer").classList.contains("on")) render() },60000);
|
||
}
|
||
window.addEventListener("hashchange",()=>{ const before=S.view; route(); if(before===S.view) render(); });
|
||
boot();
|
||
</script>
|
||
</body>
|
||
</html> |