Files

288 lines
13 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>