Files
ComfyUI-OpenClaw/web/admin_console.html
T

129 lines
20 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Remote Admin</title>
<style>
:root { --bg:#0b1017; --bg2:#141d28; --line:#2a3a4d; --fg:#e6eef7; --muted:#98abc1; --ok:#36c275; --warn:#f0b44c; --err:#ef6f79; }
* { box-sizing:border-box; }
body { margin:0; font-family:"Segoe UI",system-ui,sans-serif; color:var(--fg); background:radial-gradient(circle at top right,#12334f 0%,var(--bg) 42%); }
.shell { max-width:1100px; margin:0 auto; padding:12px; display:grid; gap:10px; }
.panel { background:rgba(20,29,40,.94); border:1px solid var(--line); border-radius:12px; padding:10px; display:grid; gap:8px; }
.hero { background:linear-gradient(120deg,rgba(51,176,255,.15),rgba(89,224,170,.14)); }
.row { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.tools { display:flex; flex-wrap:wrap; gap:8px; }
.grid { display:grid; gap:10px; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); }
.wide { grid-column:1 / -1; }
h1,h2 { margin:0; font-size:15px; letter-spacing:.4px; }
h2 { font-size:13px; text-transform:uppercase; color:#c4d5e7; }
.muted,.tiny { color:var(--muted); font-size:12px; }
.tiny { font-size:11px; }
input,textarea,button { font:inherit; color:var(--fg); border-radius:8px; border:1px solid var(--line); background:var(--bg2); padding:8px; }
textarea { min-height:80px; resize:vertical; font-family:Consolas,monospace; font-size:12px; }
button { cursor:pointer; background:linear-gradient(180deg,#224263,#17314c); }
button.subtle { background:#202b38; }
button.warn { background:#614315; }
button.danger { background:#612029; }
.status { font-size:12px; min-height:16px; }
.ok { color:var(--ok); } .warn-txt { color:var(--warn); } .err { color:var(--err); }
.kv { display:grid; grid-template-columns:auto 1fr; gap:4px 8px; font-size:12px; }
.kv .k { color:var(--muted); }
.box { max-height:220px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:8px; background:#101822; white-space:pre-wrap; word-break:break-word; font-family:Consolas,monospace; font-size:12px; }
.list { max-height:260px; overflow:auto; display:grid; gap:8px; }
.item { border:1px solid var(--line); border-radius:8px; padding:8px; background:#101822; display:grid; gap:6px; font-size:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip { border:1px solid var(--line); border-radius:999px; padding:3px 8px; font-size:11px; color:var(--muted); background:rgba(14,23,34,.9); }
@media (max-width:760px){ .row{grid-template-columns:1fr;} }
</style>
</head>
<body>
<div class="shell">
<section class="panel hero">
<h1>OpenClaw Remote Admin Console</h1>
<div class="muted">Standalone mobile-friendly admin UI. Write APIs still enforce backend token/remote policy.</div>
<div class="row">
<input id="token" type="password" placeholder="Admin token (X-OpenClaw-Admin-Token)" />
<div class="tools">
<button id="saveToken">Save</button><button id="clearToken" class="subtle">Clear</button><button id="refreshAll">Refresh All</button>
</div>
</div>
<div class="chips" id="chips"></div>
<div id="globalStatus" class="status"></div>
</section>
<section class="grid">
<article class="panel"><h2>Dashboard</h2><div class="kv" id="dashKv"></div><div class="box" id="errorsBox"></div></article>
<article class="panel">
<h2>Jobs / Events</h2>
<div class="tools"><button id="refreshRuns">Refresh Runs</button><button id="connectSse">Connect SSE</button><button id="disconnectSse" class="subtle">Disconnect SSE</button></div>
<div id="eventsStatus" class="status tiny">idle</div>
<div class="list" id="runsList"></div>
<div class="box" id="eventsBox"></div>
</article>
<article class="panel"><h2>Approvals</h2><button id="refreshApprovals">Refresh Pending</button><div class="list" id="approvalsList"></div></article>
<article class="panel">
<h2>Schedules / Triggers</h2>
<button id="refreshSchedules">Refresh Schedules</button><div class="list" id="schedulesList"></div>
<div class="tiny">Manual trigger</div>
<div class="row"><input id="trigTemplate" placeholder="template_id" /><input id="trigApproval" placeholder="require_approval true/false" /></div>
<textarea id="trigInputs" placeholder='{"message":"hello"}'></textarea>
<button id="fireTrigger">Fire Trigger</button><div id="trigStatus" class="status"></div>
</article>
<article class="panel">
<h2>Config</h2>
<div class="row"><input id="cfgProvider" placeholder="provider" /><input id="cfgModel" placeholder="model" /></div>
<div class="row"><input id="cfgBase" placeholder="base_url" /><input id="cfgTimeout" type="number" placeholder="timeout_sec" /></div>
<div class="row"><input id="cfgRetries" type="number" placeholder="max_retries" /><input id="cfgKey" disabled placeholder="key state" /></div>
<div class="tools"><button id="loadCfg">Reload</button><button id="saveCfg">Save</button></div><div id="cfgStatus" class="status"></div>
</article>
<article class="panel"><h2>Doctor / Diagnostics</h2><button id="refreshDoctor">Refresh</button><div class="box" id="doctorBox"></div><div class="box" id="inventoryBox"></div></article>
<article class="panel wide"><h2>Quick Actions</h2><div class="tools"><button id="qaRetry" class="warn">Retry Last Failed Schedule</button><button id="qaModels">Refresh Model List</button><button id="qaDrill" class="danger">Run Drill</button></div><div class="box" id="quickBox"></div></article>
</section>
</div>
<script>
(function(){
const s={p:"/openclaw",lp:"/moltbot",token:localStorage.getItem("openclaw_remote_admin_token")||"",lastSeq:0,sseAbort:null};
const q=(id)=>document.getElementById(id);
const e={token:q("token"),saveToken:q("saveToken"),clearToken:q("clearToken"),refreshAll:q("refreshAll"),chips:q("chips"),globalStatus:q("globalStatus"),dashKv:q("dashKv"),errorsBox:q("errorsBox"),refreshRuns:q("refreshRuns"),connectSse:q("connectSse"),disconnectSse:q("disconnectSse"),eventsStatus:q("eventsStatus"),runsList:q("runsList"),eventsBox:q("eventsBox"),refreshApprovals:q("refreshApprovals"),approvalsList:q("approvalsList"),refreshSchedules:q("refreshSchedules"),schedulesList:q("schedulesList"),trigTemplate:q("trigTemplate"),trigApproval:q("trigApproval"),trigInputs:q("trigInputs"),fireTrigger:q("fireTrigger"),trigStatus:q("trigStatus"),cfgProvider:q("cfgProvider"),cfgModel:q("cfgModel"),cfgBase:q("cfgBase"),cfgTimeout:q("cfgTimeout"),cfgRetries:q("cfgRetries"),cfgKey:q("cfgKey"),loadCfg:q("loadCfg"),saveCfg:q("saveCfg"),cfgStatus:q("cfgStatus"),refreshDoctor:q("refreshDoctor"),doctorBox:q("doctorBox"),inventoryBox:q("inventoryBox"),qaRetry:q("qaRetry"),qaModels:q("qaModels"),qaDrill:q("qaDrill"),quickBox:q("quickBox")};
e.token.value=s.token;
const now=()=>new Date().toLocaleTimeString();
const setStatus=(node,msg,cls)=>{node.textContent=msg||"";node.className="status"+(cls?" "+cls:"");};
const box=(node,v)=>{node.textContent=typeof v==="string"?v:JSON.stringify(v,null,2);};
const ah=()=>({"X-OpenClaw-Admin-Token":s.token||"","X-Moltbot-Admin-Token":s.token||"","X-OpenClaw-Obs-Token":s.token||"","X-Moltbot-Obs-Token":s.token||""});
async function api(path,opt){const o=opt||{};const urls=[s.p+path,s.lp+path];let last={ok:false,status:0,error:"request_failed"};for(const u of urls){try{const r=await fetch(u,{method:o.method||"GET",headers:Object.assign({},ah(),o.body?{"Content-Type":"application/json"}:{},o.headers||{}),body:o.body?JSON.stringify(o.body):undefined});const t=await r.text();let d=null;try{d=t?JSON.parse(t):null;}catch(_){d={raw:t};}if(r.status===404){last={ok:false,status:404,error:"not_found",data:d};continue;}return {ok:r.ok,status:r.status,error:(d&&d.error)||(!r.ok?r.statusText:""),data:d,url:u};}catch(err){last={ok:false,status:0,error:String(err)};}}return last;}
function appendEvt(obj){const line="["+now()+"] "+JSON.stringify(obj);const arr=e.eventsBox.textContent?e.eventsBox.textContent.split("\n"):[];arr.push(line);e.eventsBox.textContent=arr.slice(-120).join("\n");e.eventsBox.scrollTop=e.eventsBox.scrollHeight;const seq=Number(obj&&obj.seq);if(!Number.isNaN(seq)&&seq>s.lastSeq)s.lastSeq=seq;}
function parseSse(chunk){const lines=chunk.split(/\r?\n/);let type="message";const ds=[];for(const ln of lines){if(ln.startsWith("event:"))type=ln.slice(6).trim()||"message";else if(ln.startsWith("data:"))ds.push(ln.slice(5).trim());}if(!ds.length)return null;const raw=ds.join("\n");let payload=null;try{payload=JSON.parse(raw);}catch(_){payload={raw};}payload.event_type=payload.event_type||type;return payload;}
async function loadDashboard(){const [h,l,sc,rs]=await Promise.all([api("/health"),api("/logs/tail?lines=120"),api("/schedules"),api("/runs?limit=20")]);if(!h.ok){setStatus(e.globalStatus,"Health fetch failed: "+(h.error||"unknown"),"err");return;}const health=h.data||{};const cfg=health.config||{};const stats=health.stats||{};const schedules=(sc.ok&&sc.data&&Array.isArray(sc.data.schedules))?sc.data.schedules:[];const enabled=schedules.filter(x=>!!x.enabled).length;const runs=(rs.ok&&rs.data&&Array.isArray(rs.data.runs))?rs.data.runs:[];const failed=runs.filter(r=>String(r.status||"").toLowerCase().includes("fail")).length;e.chips.innerHTML="";["Version "+((health.pack&&health.pack.version)||"n/a"),"Provider "+(cfg.provider||"n/a"),"API Key "+(cfg.llm_key_configured?"Configured":"Missing"),"Schedules "+enabled+"/"+schedules.length,"Failed runs "+failed,"Uptime "+Math.floor(Number(health.uptime_sec||0))+"s"].forEach(txt=>{const c=document.createElement("span");c.className="chip";c.textContent=txt;e.chips.appendChild(c);});e.dashKv.innerHTML="";const kv={provider:cfg.provider||"n/a",model:cfg.model||"n/a",api_key:cfg.llm_key_configured?"configured":"missing",runtime_profile:health.runtime_profile||"n/a",scheduler_enabled:enabled+"/"+schedules.length,logs_processed:(stats.logs_processed!=null?stats.logs_processed:"n/a"),errors_captured:(stats.errors_captured!=null?stats.errors_captured:"n/a")};Object.keys(kv).forEach(k=>{const dk=document.createElement("div");dk.className="k";dk.textContent=k;const dv=document.createElement("div");dv.textContent=String(kv[k]);e.dashKv.appendChild(dk);e.dashKv.appendChild(dv);});const lines=(l.ok&&l.data&&Array.isArray(l.data.content))?l.data.content:[];const errLines=lines.filter(ln=>/\bERROR\b|Traceback|Exception/i.test(String(ln))).slice(-30);e.errorsBox.textContent=errLines.length?errLines.join("\n"):"No recent error lines.";e.cfgKey.value=cfg.llm_key_configured?"Configured":"Missing";setStatus(e.globalStatus,"Dashboard refreshed at "+now(),"ok");}
async function refreshRuns(){const r=await api("/runs?limit=30");if(!r.ok){setStatus(e.eventsStatus,"Runs fetch failed: "+(r.error||"unknown"),"err");return;}const runs=(r.data&&r.data.runs)||[];e.runsList.innerHTML="";if(!runs.length){e.runsList.innerHTML='<div class="tiny">No run records.</div>';return;}runs.forEach(run=>{const d=document.createElement("div");d.className="item";d.innerHTML='<div><b>'+(run.run_id||"run")+'</b></div><div class="tiny">status='+(run.status||"n/a")+' schedule='+(run.schedule_id||"n/a")+'</div><div class="tiny">template='+(run.template_id||"n/a")+' at='+(run.started_at||run.created_at||"n/a")+'</div>';e.runsList.appendChild(d);});setStatus(e.eventsStatus,"Runs refreshed at "+now(),"ok");}
async function pollEvents(){const r=await api("/events?since="+encodeURIComponent(String(s.lastSeq))+"&limit=50");if(!r.ok){setStatus(e.eventsStatus,"Events poll failed: "+(r.error||"unknown"),"err");return;}const ev=(r.data&&r.data.events)||[];ev.forEach(appendEvt);setStatus(e.eventsStatus,"Polled "+ev.length+" events","ok");}
async function connectSse(){disconnectSse();const c=new AbortController();s.sseAbort=c;let resp=null;for(const u of [s.p+"/events/stream",s.lp+"/events/stream"]){try{resp=await fetch(u,{method:"GET",headers:Object.assign({Accept:"text/event-stream"},ah()),signal:c.signal});if(resp.status!==404)break;}catch(_){resp=null;}}if(!resp||!resp.ok||!resp.body){setStatus(e.eventsStatus,"SSE unavailable; polling fallback","warn-txt");await pollEvents();return;}setStatus(e.eventsStatus,"SSE connected","ok");
const reader=resp.body.getReader();const dec=new TextDecoder();let buf="";try{while(true){const step=await reader.read();if(step.done)break;buf+=dec.decode(step.value,{stream:true});const chunks=buf.split(/\r?\n\r?\n/);buf=chunks.pop()||"";chunks.forEach(ch=>{if(!ch||ch.startsWith(":"))return;const evt=parseSse(ch);if(evt)appendEvt(evt);});}}catch(err){if(!c.signal.aborted){setStatus(e.eventsStatus,"SSE interrupted; polling fallback","warn-txt");await pollEvents();}}}
function disconnectSse(){if(s.sseAbort){s.sseAbort.abort();s.sseAbort=null;setStatus(e.eventsStatus,"SSE disconnected","warn-txt");}}
async function refreshApprovals(){const r=await api("/approvals?status=pending&limit=60&offset=0");e.approvalsList.innerHTML="";if(!r.ok){e.approvalsList.innerHTML='<div class="tiny err">Approvals fetch failed: '+(r.error||"unknown")+'</div>';return;}const arr=(r.data&&r.data.approvals)||[];if(!arr.length){e.approvalsList.innerHTML='<div class="tiny">No pending approvals.</div>';return;}arr.forEach(a=>{const it=document.createElement("div");it.className="item";it.innerHTML='<div><b>'+(a.approval_id||"approval")+'</b></div><div class="tiny">template='+(a.template_id||"n/a")+' source='+(a.source||"n/a")+'</div>';const bar=document.createElement("div");bar.className="tools";const ap=document.createElement("button");ap.textContent="Approve";ap.onclick=async()=>{const x=await api("/approvals/"+encodeURIComponent(a.approval_id)+"/approve",{method:"POST",body:{actor:"remote_admin",auto_execute:true}});appendEvt({event_type:"approval_approve",id:a.approval_id,ok:x.ok,detail:x.data||x.error});await refreshApprovals();await refreshRuns();};const rej=document.createElement("button");rej.className="danger";rej.textContent="Reject";rej.onclick=async()=>{const x=await api("/approvals/"+encodeURIComponent(a.approval_id)+"/reject",{method:"POST",body:{actor:"remote_admin"}});appendEvt({event_type:"approval_reject",id:a.approval_id,ok:x.ok,detail:x.data||x.error});await refreshApprovals();};bar.appendChild(ap);bar.appendChild(rej);it.appendChild(bar);e.approvalsList.appendChild(it);});}
async function refreshSchedules(){const r=await api("/schedules");e.schedulesList.innerHTML="";if(!r.ok){e.schedulesList.innerHTML='<div class="tiny err">Schedules fetch failed: '+(r.error||"unknown")+'</div>';return;}const arr=(r.data&&r.data.schedules)||[];if(!arr.length){e.schedulesList.innerHTML='<div class="tiny">No schedules configured.</div>';return;}arr.forEach(sch=>{const it=document.createElement("div");it.className="item";it.innerHTML='<div><b>'+(sch.name||sch.schedule_id)+'</b></div><div class="tiny">id='+sch.schedule_id+' enabled='+(!!sch.enabled)+' trigger='+(sch.trigger_type||"n/a")+'</div><div class="tiny">template='+(sch.template_id||"n/a")+'</div>';const bar=document.createElement("div");bar.className="tools";const tg=document.createElement("button");tg.className="subtle";tg.textContent="Toggle";tg.onclick=async()=>{const x=await api("/schedules/"+encodeURIComponent(sch.schedule_id)+"/toggle",{method:"POST"});appendEvt({event_type:"schedule_toggle",schedule_id:sch.schedule_id,ok:x.ok,detail:x.data||x.error});await refreshSchedules();await loadDashboard();};const rn=document.createElement("button");rn.textContent="Run Now";rn.onclick=async()=>{const x=await api("/schedules/"+encodeURIComponent(sch.schedule_id)+"/run",{method:"POST"});appendEvt({event_type:"schedule_run",schedule_id:sch.schedule_id,ok:x.ok,detail:x.data||x.error});await refreshRuns();};bar.appendChild(tg);bar.appendChild(rn);it.appendChild(bar);e.schedulesList.appendChild(it);});}
async function fireTrigger(){setStatus(e.trigStatus,"Submitting trigger...","warn-txt");const tid=e.trigTemplate.value.trim();if(!tid){setStatus(e.trigStatus,"template_id is required","err");return;}let inputs={};if(e.trigInputs.value.trim()){try{inputs=JSON.parse(e.trigInputs.value);}catch(err){setStatus(e.trigStatus,"inputs JSON parse error: "+String(err),"err");return;}}const raw=e.trigApproval.value.trim().toLowerCase();const req=raw==="true"||raw==="1";const r=await api("/triggers/fire",{method:"POST",body:{template_id:tid,inputs:inputs,require_approval:req}});if(!r.ok){setStatus(e.trigStatus,"Trigger failed: "+(r.error||"unknown"),"err");return;}setStatus(e.trigStatus,"Trigger accepted","ok");appendEvt({event_type:"trigger_fire",detail:r.data});await refreshApprovals();await refreshRuns();}
async function loadConfig(){const r=await api("/config");if(!r.ok){setStatus(e.cfgStatus,"Config read failed: "+(r.error||"unknown"),"err");return;}const c=(r.data&&r.data.config)||{};e.cfgProvider.value=c.provider||"";e.cfgModel.value=c.model||"";e.cfgBase.value=c.base_url||"";e.cfgTimeout.value=c.timeout_sec!=null?String(c.timeout_sec):"";e.cfgRetries.value=c.max_retries!=null?String(c.max_retries):"";setStatus(e.cfgStatus,"Config loaded","ok");}
async function saveConfig(){const body={provider:e.cfgProvider.value.trim(),model:e.cfgModel.value.trim(),base_url:e.cfgBase.value.trim(),timeout_sec:Number(e.cfgTimeout.value||"0")||120,max_retries:Number(e.cfgRetries.value||"0")||0};const r=await api("/config",{method:"PUT",body:body});if(!r.ok){setStatus(e.cfgStatus,"Config save failed: "+(r.error||"unknown"),"err");box(e.quickBox,r.data||r);return;}setStatus(e.cfgStatus,"Config saved","ok");await loadDashboard();}
async function refreshDoctor(){const [d,i]=await Promise.all([api("/security/doctor"),api("/preflight/inventory")]);box(e.doctorBox,d.ok?d.data:{error:d.error,status:d.status,data:d.data});box(e.inventoryBox,i.ok?i.data:{error:i.error,status:i.status,data:i.data});}
async function qaRetry(){const r=await api("/runs?status=failed&limit=1");if(!r.ok){box(e.quickBox,{action:"retry_failed",ok:false,error:r.error,detail:r.data});return;}const runs=(r.data&&r.data.runs)||[];if(!runs.length){box(e.quickBox,{action:"retry_failed",ok:false,error:"no_failed_run_found"});return;}const run=runs[0];if(!run.schedule_id){box(e.quickBox,{action:"retry_failed",ok:false,error:"failed_run_has_no_schedule_id",run:run});return;}const x=await api("/schedules/"+encodeURIComponent(run.schedule_id)+"/run",{method:"POST"});box(e.quickBox,{action:"retry_failed",ok:x.ok,target_schedule:run.schedule_id,detail:x.data||x.error});await refreshRuns();}
async function qaModels(){const p=(e.cfgProvider.value||"").trim();const q=p?("?provider="+encodeURIComponent(p)):"";const r=await api("/llm/models"+q);box(e.quickBox,{action:"refresh_models",ok:r.ok,detail:r.data||r.error});}
async function qaDrill(){if(!window.confirm("Run drill via Tools API? This is an admin action."))return;const list=await api("/tools");if(!list.ok){box(e.quickBox,{action:"run_drill",ok:false,error:list.error,detail:list.data});return;}const tools=(list.data&&list.data.tools)||[];const m=tools.find(t=>/drill|crypto/i.test(String(t.name||"")));if(!m){box(e.quickBox,{action:"run_drill",ok:false,error:"no_drill_tool_found",tools:tools.map(t=>t.name)});return;}const r=await api("/tools/"+encodeURIComponent(m.name)+"/run",{method:"POST",body:{args:{scenarios:"planned_rotation,token_compromise"}}});box(e.quickBox,{action:"run_drill",tool:m.name,ok:r.ok,detail:r.data||r.error});}
async function refreshAll(){await Promise.all([loadDashboard(),refreshRuns(),refreshApprovals(),refreshSchedules(),loadConfig(),refreshDoctor()]);}
e.saveToken.onclick=()=>{s.token=e.token.value.trim();localStorage.setItem("openclaw_remote_admin_token",s.token);setStatus(e.globalStatus,"Token saved locally in this browser","ok");};
e.clearToken.onclick=()=>{s.token="";e.token.value="";localStorage.removeItem("openclaw_remote_admin_token");setStatus(e.globalStatus,"Token cleared","warn-txt");};
e.refreshAll.onclick=refreshAll;e.refreshRuns.onclick=refreshRuns;e.connectSse.onclick=connectSse;e.disconnectSse.onclick=disconnectSse;e.refreshApprovals.onclick=refreshApprovals;e.refreshSchedules.onclick=refreshSchedules;e.fireTrigger.onclick=fireTrigger;e.loadCfg.onclick=loadConfig;e.saveCfg.onclick=saveConfig;e.refreshDoctor.onclick=refreshDoctor;e.qaRetry.onclick=qaRetry;e.qaModels.onclick=qaModels;e.qaDrill.onclick=qaDrill;
window.addEventListener("beforeunload",disconnectSse);
refreshAll();
})();
</script>
</body>
</html>