288 lines
13 KiB
HTML
288 lines
13 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="robots" content="noindex,nofollow">
|
||
<title>Studio Admin</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0b1220; --panel:#121b2e; --panel2:#0f1828; --line:#1e2b45;
|
||
--fg:#edf2f9; --muted:#7b8aa3; --accent:#4f8cff;
|
||
--ok:#34d399; --warn:#fbbf24; --bad:#f87171; --run:#60a5fa;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:var(--bg);color:var(--fg);
|
||
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||
-webkit-font-smoothing:antialiased;padding-bottom:40px}
|
||
header{position:sticky;top:0;z-index:5;background:rgba(11,18,32,.92);
|
||
backdrop-filter:blur(8px);border-bottom:1px solid var(--line);
|
||
padding:14px 18px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||
header h1{font-size:17px;margin:0;font-weight:650;letter-spacing:.2px}
|
||
header .dot{width:9px;height:9px;border-radius:50%;background:var(--muted);flex:none}
|
||
header .dot.on{background:var(--ok);box-shadow:0 0 8px var(--ok)}
|
||
header .spacer{flex:1}
|
||
button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--panel);
|
||
color:var(--fg);border-radius:9px;padding:8px 13px;transition:.15s}
|
||
button:hover{border-color:var(--accent)}
|
||
button:active{transform:translateY(1px)}
|
||
button.primary{background:var(--accent);border-color:var(--accent);color:#06122b;font-weight:650}
|
||
button:disabled{opacity:.45;cursor:not-allowed}
|
||
.wrap{max-width:1100px;margin:0 auto;padding:18px}
|
||
.grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 17px}
|
||
.card h2{font-size:13px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);
|
||
margin:0 0 12px;font-weight:600;display:flex;align-items:center;gap:8px}
|
||
.row{display:flex;align-items:center;gap:10px;justify-content:space-between;
|
||
padding:9px 0;border-top:1px solid var(--line)}
|
||
.row:first-of-type{border-top:0}
|
||
.row .l{min-width:0}
|
||
.row .name{font-weight:600}
|
||
.row .sub{color:var(--muted);font-size:12.5px;margin-top:2px;white-space:nowrap;
|
||
overflow:hidden;text-overflow:ellipsis;max-width:46vw}
|
||
.badge{font-size:11.5px;font-weight:700;padding:3px 9px;border-radius:20px;flex:none;
|
||
text-transform:uppercase;letter-spacing:.4px}
|
||
.b-ok{background:rgba(52,211,153,.15);color:var(--ok)}
|
||
.b-bad{background:rgba(248,113,113,.15);color:var(--bad)}
|
||
.b-run{background:rgba(96,165,250,.15);color:var(--run)}
|
||
.b-idle{background:rgba(123,138,163,.15);color:var(--muted)}
|
||
.actions{display:flex;gap:8px;flex:none}
|
||
.actions button{padding:7px 11px;font-size:13px}
|
||
.big{font-size:30px;font-weight:700;line-height:1}
|
||
.kpis{display:flex;gap:22px;flex-wrap:wrap}
|
||
.kpi .big{margin-bottom:3px}
|
||
.kpi .lbl{color:var(--muted);font-size:12px}
|
||
a{color:var(--accent);text-decoration:none}
|
||
a:hover{text-decoration:underline}
|
||
.feeditem{display:flex;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--line)}
|
||
.feeditem:first-child{border-top:0}
|
||
.feeditem img{width:46px;height:30px;object-fit:cover;border-radius:5px;flex:none;background:var(--panel2)}
|
||
.feeditem .t{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.feeditem .s{color:var(--muted);font-size:12px}
|
||
.tl{font-size:13.5px}
|
||
.tl .e{display:flex;gap:9px;padding:7px 0;border-top:1px solid var(--line)}
|
||
.tl .e:first-child{border-top:0}
|
||
.tl .when{color:var(--muted);font-size:12px;flex:none;width:74px}
|
||
.note{color:var(--muted);font-size:13px}
|
||
.err{background:rgba(248,113,113,.12);border:1px solid var(--bad);color:#fecaca;
|
||
padding:11px 14px;border-radius:11px;margin:0 0 14px;font-size:13.5px}
|
||
dialog{background:var(--panel);color:var(--fg);border:1px solid var(--line);
|
||
border-radius:16px;padding:22px;max-width:430px;width:92%}
|
||
dialog::backdrop{background:rgba(4,8,17,.7)}
|
||
dialog h3{margin:0 0 8px}
|
||
dialog input{width:100%;padding:10px;border-radius:9px;border:1px solid var(--line);
|
||
background:var(--panel2);color:var(--fg);font:inherit;margin:10px 0}
|
||
dialog ol{margin:10px 0;padding-left:20px;color:var(--muted);font-size:13px;line-height:1.7}
|
||
dialog code{background:var(--panel2);padding:1px 6px;border-radius:5px;color:var(--fg)}
|
||
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:22px}
|
||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);
|
||
background:var(--panel);border:1px solid var(--accent);border-radius:11px;
|
||
padding:11px 18px;font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.5);opacity:0;
|
||
transition:.25s;pointer-events:none;z-index:20}
|
||
.toast.show{opacity:1}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<span class="dot" id="conn"></span>
|
||
<h1>⚙️ Studio Admin</h1>
|
||
<span class="spacer"></span>
|
||
<button id="refresh">↻ Обновить</button>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
<div id="banner"></div>
|
||
|
||
<div class="grid">
|
||
<!-- PROCESSES -->
|
||
<div class="card" style="grid-column:1/-1">
|
||
<h2>🛠 Процессы студии <span class="note" id="proc-clock"></span></h2>
|
||
<div id="procs"><div class="note">Подключи токен, чтобы увидеть статус и управлять.</div></div>
|
||
</div>
|
||
|
||
<!-- SITE FEED -->
|
||
<div class="card">
|
||
<h2>🌐 Лента сайта</h2>
|
||
<div id="feed"><div class="note">…</div></div>
|
||
</div>
|
||
|
||
<!-- LEADS -->
|
||
<div class="card">
|
||
<h2>🎯 Лиды</h2>
|
||
<div id="leads">
|
||
<div class="note">Детальная CRM — в боте (<code>/panel</code>, <code>/leads</code>).
|
||
Сводка появится здесь, когда бот начнёт публиковать статистику (следующий шаг).</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ACTIVITY -->
|
||
<div class="card" style="grid-column:1/-1">
|
||
<h2>📜 Активность (последние запуски)</h2>
|
||
<div id="activity" class="tl"><div class="note">…</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="foot">AI Automation Studio · статусы и лиды читаются публично с чашки (git.oleks.space)</div>
|
||
</div>
|
||
|
||
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// Всё читается ПУБЛИЧНО с чашки (Gitea) — токен не нужен (anton/ai-automation-studio#1).
|
||
const RAW_SITE="https://git.oleks.space/anton/ai-automation-studio-site/raw/branch/main";
|
||
const RAW_STUDIO="https://git.oleks.space/anton/ai-automation-studio/raw/branch/master";
|
||
const K8S_HINT="kubectl create job -n kotkan run-<proc> --from=cronjob/<proc>";
|
||
const PROCS=[
|
||
{key:"studio-channel-poster", name:"Постер канала", emoji:"📢",
|
||
desc:"новости + видео-реклама в @ai_bots_for_business", cron:"0 6,9,12,15 * * *"},
|
||
{key:"studio-intent-radar", name:"Радар лидов", emoji:"🔍",
|
||
desc:"ищет покупательский интент → тебе в ЛС", cron:"30 7,15 * * *"},
|
||
{key:"studio-site-feed", name:"Лента сайта", emoji:"🌐",
|
||
desc:"обновляет feed.json (видео + новости)", cron:"15 6,14 * * *"},
|
||
];
|
||
localStorage.removeItem("studio_admin_pat"); // токен больше не нужен и вычищается
|
||
|
||
const $=s=>document.querySelector(s);
|
||
const el=(t,c,h)=>{const e=document.createElement(t);if(c)e.className=c;if(h!=null)e.innerHTML=h;return e;};
|
||
function toast(m){const t=$("#toast");t.textContent=m;t.classList.add("show");setTimeout(()=>t.classList.remove("show"),2200);}
|
||
|
||
function ago(iso){
|
||
if(!iso) return "—";
|
||
const s=Math.floor((Date.now()-new Date(iso))/1000);
|
||
if(s<60) return s+"с назад";
|
||
if(s<3600) return Math.floor(s/60)+"м назад";
|
||
if(s<86400) return Math.floor(s/3600)+"ч назад";
|
||
return Math.floor(s/86400)+"д назад";
|
||
}
|
||
// next cron fire (UTC) from "m h1,h2 * * *"
|
||
function nextRun(cron){
|
||
const[mn,hrs]=cron.split(" ");
|
||
const m=+mn, hs=hrs.split(",").map(Number);
|
||
const now=new Date();
|
||
for(let d=0;d<2;d++){
|
||
for(const h of hs){
|
||
const t=new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),now.getUTCDate()+d,h,m,0));
|
||
if(t>now) return t;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
function inHuman(t){
|
||
if(!t) return "—";
|
||
const s=Math.floor((t-Date.now())/1000);
|
||
if(s<3600) return "через "+Math.floor(s/60)+"м";
|
||
if(s<86400) return "через "+Math.floor(s/3600)+"ч "+Math.floor((s%3600)/60)+"м";
|
||
return "через "+Math.floor(s/86400)+"д";
|
||
}
|
||
|
||
let STATUS=null;
|
||
async function loadStatus(){
|
||
try{
|
||
const r=await fetch(RAW_SITE+"/status.json?cb="+Date.now());
|
||
STATUS = r.ok ? await r.json() : null;
|
||
}catch(e){ STATUS=null; }
|
||
}
|
||
|
||
function badge(p){
|
||
if(!p) return ['b-idle','нет данных'];
|
||
return p.ok ? ['b-ok','успех'] : ['b-bad','сбой rc='+p.rc];
|
||
}
|
||
|
||
async function loadProcs(){
|
||
const box=$("#procs"); box.innerHTML="";
|
||
for(const wf of PROCS){
|
||
const p=STATUS&&STATUS.procs&&STATUS.procs[wf.key];
|
||
const row=el("div","row");
|
||
const[bc,bt]=badge(p);
|
||
const nr=nextRun(wf.cron);
|
||
row.appendChild(el("div","l",
|
||
`<div class="name">${wf.emoji} ${wf.name}</div>
|
||
<div class="sub">${wf.desc}</div>
|
||
<div class="sub">⏱ ${p?ago(p.last):'—'} · след. ${inHuman(nr)}</div>`));
|
||
const right=el("div","actions");
|
||
right.appendChild(el("span","badge "+bc,bt));
|
||
row.appendChild(right);
|
||
box.appendChild(row);
|
||
}
|
||
box.appendChild(el("div","note",
|
||
'Процессы крутятся в k8s (ns kotkan). Ручной запуск: <code>'+K8S_HINT+'</code>'));
|
||
}
|
||
|
||
async function loadActivity(){
|
||
const box=$("#activity");
|
||
box.innerHTML="";
|
||
const hist=(STATUS&&STATUS.history)||[];
|
||
for(const h of hist.slice(0,12)){
|
||
const e=el("div","e");
|
||
e.appendChild(el("span","when",ago(h.at)));
|
||
e.appendChild(el("span","badge "+(h.ok?'b-ok':'b-bad'),h.ok?'успех':'сбой'));
|
||
e.appendChild(el("span",null,h.proc));
|
||
box.appendChild(e);
|
||
}
|
||
if(!hist.length) box.innerHTML='<div class="note">Запусков ещё не было (status.json появится после первого прогона).</div>';
|
||
}
|
||
|
||
async function loadLeads(){
|
||
const box=$("#leads");
|
||
try{
|
||
const r=await fetch(RAW_STUDIO+"/state/leads.json?cb="+Date.now());
|
||
if(r.status===404){box.innerHTML='<div class="note">Снимок лидов ещё не опубликован ботом. Полная CRM — в боте (<code>/panel</code>).</div>';return;}
|
||
if(!r.ok) throw new Error(r.status);
|
||
const d=await r.json();
|
||
box.innerHTML="";
|
||
box.appendChild(el("div","kpis",
|
||
`<div class="kpi"><div class="big">${d.total}</div><div class="lbl">всего лидов</div></div>
|
||
<div class="kpi"><div class="big">${d.today}</div><div class="lbl">сегодня</div></div>`));
|
||
if(d.by_source&&d.by_source.length)
|
||
box.appendChild(el("div","sub","Источники: "+d.by_source.map(s=>`${s.source} · ${s.n}`).join(" ")));
|
||
(d.recent||[]).forEach(L=>{
|
||
const who=[L.name,L.username?("@"+L.username):""].filter(Boolean).join(" ")||"аноним";
|
||
box.appendChild(el("div","feeditem",
|
||
`<div><div class="t">🎯 ${who} <span class="s">· ${L.source||"direct"}${L.bot?(" · "+L.bot):""}</span></div>
|
||
<div class="s">${((L.request||"").slice(0,70))||"—"}${L.contact?(" · "+L.contact):""}</div></div>`));
|
||
});
|
||
if(!(d.recent||[]).length) box.appendChild(el("div","note","Лидов пока нет."));
|
||
box.appendChild(el("div","note",`обновлено ${ago(d.generated_at)} · полная CRM в боте /panel`));
|
||
}catch(e){box.innerHTML='<div class="note">⚠ лиды: '+e.message+'</div>';}
|
||
}
|
||
|
||
async function loadFeed(){
|
||
const box=$("#feed");
|
||
try{
|
||
const r=await fetch("../feed.json?_="+Date.now());
|
||
const f=await r.json();
|
||
box.innerHTML="";
|
||
box.appendChild(el("div","kpis",
|
||
`<div class="kpi"><div class="big">${f.count}</div><div class="lbl">элементов</div></div>
|
||
<div class="kpi"><div class="big" style="font-size:15px;padding-top:9px">${f.updated||'—'}</div><div class="lbl">обновлено (UTC)</div></div>`));
|
||
(f.items||[]).slice(0,6).forEach(it=>{
|
||
const row=el("div","feeditem");
|
||
if(it.thumb) row.appendChild(Object.assign(el("img"),{src:it.thumb,loading:"lazy",onerror:function(){this.style.visibility='hidden'}}));
|
||
row.appendChild(el("div",null,
|
||
`<div class="t">${it.kind==='video'?'🎬':'📰'} ${it.title||it.id}</div>
|
||
<div class="s">${it.source||''}</div>`));
|
||
box.appendChild(row);
|
||
});
|
||
}catch(e){box.innerHTML='<div class="note">⚠ не удалось прочитать feed.json</div>';}
|
||
}
|
||
|
||
function setConn(){ $("#conn").classList.add("on"); $("#banner").innerHTML=""; }
|
||
|
||
async function refreshAll(){
|
||
setConn();
|
||
await loadStatus();
|
||
loadFeed(); loadProcs(); loadActivity(); loadLeads();
|
||
}
|
||
|
||
$("#refresh").onclick=refreshAll;
|
||
|
||
// live clocks
|
||
setInterval(()=>{$("#proc-clock").textContent="· "+new Date().toUTCString().slice(17,25)+" UTC";},1000);
|
||
|
||
refreshAll();
|
||
</script>
|
||
</body>
|
||
</html>
|