mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 00:48:07 +00:00
refactor: decompose frontend shell and admin console
This commit is contained in:
+2
-42
@@ -88,49 +88,9 @@
|
||||
</section>
|
||||
</div>
|
||||
<script type="module">
|
||||
import {
|
||||
API_PREFIXES,
|
||||
STORAGE_KEYS,
|
||||
buildRemoteAdminHeaders,
|
||||
getMirroredStorageValue,
|
||||
setMirroredStorageValue,
|
||||
} from "./openclaw_compat.js";
|
||||
import { mountAdminConsole } from "./admin_console_app.js";
|
||||
|
||||
(function(){
|
||||
const s={p:API_PREFIXES.canonical,lp:API_PREFIXES.legacy,token:getMirroredStorageValue(localStorage, STORAGE_KEYS.local.remoteAdminToken)||"",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=()=>buildRemoteAdminHeaders(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();setMirroredStorageValue(localStorage, STORAGE_KEYS.local.remoteAdminToken, s.token);setStatus(e.globalStatus,"Token saved locally in this browser","ok");};
|
||||
e.clearToken.onclick=()=>{s.token="";e.token.value="";setMirroredStorageValue(localStorage, STORAGE_KEYS.local.remoteAdminToken, "");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();
|
||||
})();
|
||||
mountAdminConsole(document);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
API_PREFIXES,
|
||||
STORAGE_KEYS,
|
||||
buildRemoteAdminHeaders,
|
||||
getMirroredStorageValue,
|
||||
setMirroredStorageValue,
|
||||
} from "./openclaw_compat.js";
|
||||
|
||||
function parseResponseText(text) {
|
||||
try {
|
||||
return text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return { raw: text };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSseChunk(chunk) {
|
||||
const lines = String(chunk || "").split(/\r?\n/);
|
||||
let type = "message";
|
||||
const dataLines = [];
|
||||
lines.forEach((line) => {
|
||||
if (line.startsWith("event:")) {
|
||||
type = line.slice(6).trim() || "message";
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
});
|
||||
if (!dataLines.length) return null;
|
||||
|
||||
const raw = dataLines.join("\n");
|
||||
const payload = parseResponseText(raw) || { raw };
|
||||
payload.event_type = payload.event_type || type;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createRemoteAdminApi({
|
||||
fetchImpl = fetch.bind(globalThis),
|
||||
storage = globalThis.localStorage,
|
||||
tokenSpec = STORAGE_KEYS.local.remoteAdminToken,
|
||||
prefixes = API_PREFIXES,
|
||||
headerBuilder = buildRemoteAdminHeaders,
|
||||
} = {}) {
|
||||
const state = {
|
||||
canonicalPrefix: prefixes.canonical,
|
||||
legacyPrefix: prefixes.legacy,
|
||||
token: getMirroredStorageValue(storage, tokenSpec) || "",
|
||||
lastSeq: 0,
|
||||
sseAbort: null,
|
||||
};
|
||||
|
||||
const getHeaders = () => headerBuilder(state.token || "");
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const urls = [`${state.canonicalPrefix}${path}`, `${state.legacyPrefix}${path}`];
|
||||
let last = { ok: false, status: 0, error: "request_failed" };
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: options.method || "GET",
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = parseResponseText(text);
|
||||
if (response.status === 404) {
|
||||
last = { ok: false, status: 404, error: "not_found", data };
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
error: (data && data.error) || (!response.ok ? response.statusText : ""),
|
||||
data,
|
||||
url,
|
||||
};
|
||||
} catch (error) {
|
||||
last = { ok: false, status: 0, error: String(error) };
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function openStream(path, options = {}) {
|
||||
const urls = [`${state.canonicalPrefix}${path}`, `${state.legacyPrefix}${path}`];
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: options.method || "GET",
|
||||
headers: {
|
||||
...getHeaders(),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// try next compatible path
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
request,
|
||||
openStream,
|
||||
getHeaders,
|
||||
getToken() {
|
||||
return state.token;
|
||||
},
|
||||
setToken(token) {
|
||||
state.token = String(token || "").trim();
|
||||
setMirroredStorageValue(storage, tokenSpec, state.token);
|
||||
return state.token;
|
||||
},
|
||||
clearToken() {
|
||||
state.token = "";
|
||||
setMirroredStorageValue(storage, tokenSpec, "");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import { createRemoteAdminApi, parseSseChunk } from "./admin_console_api.js";
|
||||
|
||||
function query(root, id) {
|
||||
return root.getElementById(id);
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function setStatus(node, message, className = "") {
|
||||
node.textContent = message || "";
|
||||
node.className = `status${className ? ` ${className}` : ""}`;
|
||||
}
|
||||
|
||||
function fillBox(node, value) {
|
||||
node.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function createElements(root) {
|
||||
return {
|
||||
token: query(root, "token"),
|
||||
saveToken: query(root, "saveToken"),
|
||||
clearToken: query(root, "clearToken"),
|
||||
refreshAll: query(root, "refreshAll"),
|
||||
chips: query(root, "chips"),
|
||||
globalStatus: query(root, "globalStatus"),
|
||||
dashKv: query(root, "dashKv"),
|
||||
errorsBox: query(root, "errorsBox"),
|
||||
refreshRuns: query(root, "refreshRuns"),
|
||||
connectSse: query(root, "connectSse"),
|
||||
disconnectSse: query(root, "disconnectSse"),
|
||||
eventsStatus: query(root, "eventsStatus"),
|
||||
runsList: query(root, "runsList"),
|
||||
eventsBox: query(root, "eventsBox"),
|
||||
refreshApprovals: query(root, "refreshApprovals"),
|
||||
approvalsList: query(root, "approvalsList"),
|
||||
refreshSchedules: query(root, "refreshSchedules"),
|
||||
schedulesList: query(root, "schedulesList"),
|
||||
trigTemplate: query(root, "trigTemplate"),
|
||||
trigApproval: query(root, "trigApproval"),
|
||||
trigInputs: query(root, "trigInputs"),
|
||||
fireTrigger: query(root, "fireTrigger"),
|
||||
trigStatus: query(root, "trigStatus"),
|
||||
cfgProvider: query(root, "cfgProvider"),
|
||||
cfgModel: query(root, "cfgModel"),
|
||||
cfgBase: query(root, "cfgBase"),
|
||||
cfgTimeout: query(root, "cfgTimeout"),
|
||||
cfgRetries: query(root, "cfgRetries"),
|
||||
cfgKey: query(root, "cfgKey"),
|
||||
loadCfg: query(root, "loadCfg"),
|
||||
saveCfg: query(root, "saveCfg"),
|
||||
cfgStatus: query(root, "cfgStatus"),
|
||||
refreshDoctor: query(root, "refreshDoctor"),
|
||||
doctorBox: query(root, "doctorBox"),
|
||||
inventoryBox: query(root, "inventoryBox"),
|
||||
qaRetry: query(root, "qaRetry"),
|
||||
qaModels: query(root, "qaModels"),
|
||||
qaDrill: query(root, "qaDrill"),
|
||||
quickBox: query(root, "quickBox"),
|
||||
};
|
||||
}
|
||||
|
||||
export function mountAdminConsole(root = document) {
|
||||
const view = root.defaultView || window;
|
||||
const api = createRemoteAdminApi({
|
||||
fetchImpl: view.fetch.bind(view),
|
||||
storage: view.localStorage,
|
||||
});
|
||||
const elements = createElements(root);
|
||||
elements.token.value = api.getToken();
|
||||
|
||||
function appendEvent(obj) {
|
||||
const line = `[${now()}] ${JSON.stringify(obj)}`;
|
||||
const lines = elements.eventsBox.textContent ? elements.eventsBox.textContent.split("\n") : [];
|
||||
lines.push(line);
|
||||
elements.eventsBox.textContent = lines.slice(-120).join("\n");
|
||||
elements.eventsBox.scrollTop = elements.eventsBox.scrollHeight;
|
||||
const seq = Number(obj?.seq);
|
||||
if (!Number.isNaN(seq) && seq > api.state.lastSeq) {
|
||||
api.state.lastSeq = seq;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const [healthRes, logsRes, schedulesRes, runsRes] = await Promise.all([
|
||||
api.request("/health"),
|
||||
api.request("/logs/tail?lines=120"),
|
||||
api.request("/schedules"),
|
||||
api.request("/runs?limit=20"),
|
||||
]);
|
||||
if (!healthRes.ok) {
|
||||
setStatus(elements.globalStatus, `Health fetch failed: ${healthRes.error || "unknown"}`, "err");
|
||||
return;
|
||||
}
|
||||
|
||||
const health = healthRes.data || {};
|
||||
const config = health.config || {};
|
||||
const stats = health.stats || {};
|
||||
const schedules = schedulesRes.ok && Array.isArray(schedulesRes.data?.schedules)
|
||||
? schedulesRes.data.schedules
|
||||
: [];
|
||||
const runs = runsRes.ok && Array.isArray(runsRes.data?.runs)
|
||||
? runsRes.data.runs
|
||||
: [];
|
||||
const enabledCount = schedules.filter((item) => Boolean(item.enabled)).length;
|
||||
const failedRuns = runs.filter((item) => String(item.status || "").toLowerCase().includes("fail")).length;
|
||||
|
||||
elements.chips.innerHTML = "";
|
||||
[
|
||||
`Version ${(health.pack && health.pack.version) || "n/a"}`,
|
||||
`Provider ${config.provider || "n/a"}`,
|
||||
`API Key ${config.llm_key_configured ? "Configured" : "Missing"}`,
|
||||
`Schedules ${enabledCount}/${schedules.length}`,
|
||||
`Failed runs ${failedRuns}`,
|
||||
`Uptime ${Math.floor(Number(health.uptime_sec || 0))}s`,
|
||||
].forEach((text) => {
|
||||
const chip = root.createElement("span");
|
||||
chip.className = "chip";
|
||||
chip.textContent = text;
|
||||
elements.chips.appendChild(chip);
|
||||
});
|
||||
|
||||
const kv = {
|
||||
profile: health.deployment_profile || "n/a",
|
||||
control_plane: health.control_plane?.mode || "n/a",
|
||||
pack: health.pack?.name || "n/a",
|
||||
approvals_pending: Number(stats.approvals_pending || 0),
|
||||
queue_depth: Number(stats.queue_depth || 0),
|
||||
observability_dropped: Number(stats.observability?.total_dropped || 0),
|
||||
};
|
||||
elements.dashKv.innerHTML = "";
|
||||
Object.entries(kv).forEach(([key, value]) => {
|
||||
const keyNode = root.createElement("div");
|
||||
keyNode.className = "k";
|
||||
keyNode.textContent = key;
|
||||
const valueNode = root.createElement("div");
|
||||
valueNode.textContent = String(value);
|
||||
elements.dashKv.appendChild(keyNode);
|
||||
elements.dashKv.appendChild(valueNode);
|
||||
});
|
||||
|
||||
const errorLines = logsRes.ok && typeof logsRes.data?.tail === "string"
|
||||
? logsRes.data.tail
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /\b(error|traceback|fatal|critical)\b/i.test(line))
|
||||
.slice(-40)
|
||||
: [];
|
||||
fillBox(elements.errorsBox, errorLines.length ? errorLines.join("\n") : "No recent error lines.");
|
||||
elements.cfgKey.value = config.llm_key_configured ? "Configured" : "Missing";
|
||||
setStatus(elements.globalStatus, `Dashboard refreshed at ${now()}`, "ok");
|
||||
}
|
||||
|
||||
async function refreshRuns() {
|
||||
const response = await api.request("/runs?limit=30");
|
||||
if (!response.ok) {
|
||||
setStatus(elements.eventsStatus, `Runs fetch failed: ${response.error || "unknown"}`, "err");
|
||||
return;
|
||||
}
|
||||
|
||||
const runs = response.data?.runs || [];
|
||||
elements.runsList.innerHTML = "";
|
||||
if (!runs.length) {
|
||||
elements.runsList.innerHTML = '<div class="tiny">No run records.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
runs.forEach((run) => {
|
||||
const node = root.createElement("div");
|
||||
node.className = "item";
|
||||
node.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>
|
||||
`;
|
||||
elements.runsList.appendChild(node);
|
||||
});
|
||||
setStatus(elements.eventsStatus, `Runs refreshed at ${now()}`, "ok");
|
||||
}
|
||||
|
||||
async function pollEvents() {
|
||||
const response = await api.request(`/events?since=${encodeURIComponent(String(api.state.lastSeq))}&limit=50`);
|
||||
if (!response.ok) {
|
||||
setStatus(elements.eventsStatus, `Events poll failed: ${response.error || "unknown"}`, "err");
|
||||
return;
|
||||
}
|
||||
const events = response.data?.events || [];
|
||||
events.forEach(appendEvent);
|
||||
setStatus(elements.eventsStatus, `Polled ${events.length} events`, "ok");
|
||||
}
|
||||
|
||||
function disconnectSse() {
|
||||
if (!api.state.sseAbort) return;
|
||||
api.state.sseAbort.abort();
|
||||
api.state.sseAbort = null;
|
||||
setStatus(elements.eventsStatus, "SSE disconnected", "warn-txt");
|
||||
}
|
||||
|
||||
async function connectSse() {
|
||||
disconnectSse();
|
||||
const controller = new AbortController();
|
||||
api.state.sseAbort = controller;
|
||||
|
||||
const response = await api.openStream("/events/stream", {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response || !response.ok || !response.body) {
|
||||
setStatus(elements.eventsStatus, "SSE unavailable; polling fallback", "warn-txt");
|
||||
await pollEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(elements.eventsStatus, "SSE connected", "ok");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const step = await reader.read();
|
||||
if (step.done) break;
|
||||
buffer += decoder.decode(step.value, { stream: true });
|
||||
const chunks = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = chunks.pop() || "";
|
||||
chunks.forEach((chunk) => {
|
||||
if (!chunk || chunk.startsWith(":")) return;
|
||||
const event = parseSseChunk(chunk);
|
||||
if (event) appendEvent(event);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setStatus(elements.eventsStatus, "SSE interrupted; polling fallback", "warn-txt");
|
||||
await pollEvents();
|
||||
}
|
||||
return error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshApprovals() {
|
||||
const response = await api.request("/approvals?status=pending&limit=60&offset=0");
|
||||
elements.approvalsList.innerHTML = "";
|
||||
if (!response.ok) {
|
||||
elements.approvalsList.innerHTML = `<div class="tiny err">Approvals fetch failed: ${response.error || "unknown"}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const approvals = response.data?.approvals || [];
|
||||
if (!approvals.length) {
|
||||
elements.approvalsList.innerHTML = '<div class="tiny">No pending approvals.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
approvals.forEach((approval) => {
|
||||
const item = root.createElement("div");
|
||||
item.className = "item";
|
||||
item.innerHTML = `
|
||||
<div><b>${approval.approval_id || "approval"}</b></div>
|
||||
<div class="tiny">template=${approval.template_id || "n/a"} source=${approval.source || "n/a"}</div>
|
||||
`;
|
||||
const bar = root.createElement("div");
|
||||
bar.className = "tools";
|
||||
|
||||
const approveButton = root.createElement("button");
|
||||
approveButton.textContent = "Approve";
|
||||
approveButton.onclick = async () => {
|
||||
const result = await api.request(`/approvals/${encodeURIComponent(approval.approval_id)}/approve`, {
|
||||
method: "POST",
|
||||
body: { actor: "remote_admin", auto_execute: true },
|
||||
});
|
||||
appendEvent({ event_type: "approval_approve", id: approval.approval_id, ok: result.ok, detail: result.data || result.error });
|
||||
await refreshApprovals();
|
||||
await refreshRuns();
|
||||
};
|
||||
|
||||
const rejectButton = root.createElement("button");
|
||||
rejectButton.className = "danger";
|
||||
rejectButton.textContent = "Reject";
|
||||
rejectButton.onclick = async () => {
|
||||
const result = await api.request(`/approvals/${encodeURIComponent(approval.approval_id)}/reject`, {
|
||||
method: "POST",
|
||||
body: { actor: "remote_admin" },
|
||||
});
|
||||
appendEvent({ event_type: "approval_reject", id: approval.approval_id, ok: result.ok, detail: result.data || result.error });
|
||||
await refreshApprovals();
|
||||
};
|
||||
|
||||
bar.appendChild(approveButton);
|
||||
bar.appendChild(rejectButton);
|
||||
item.appendChild(bar);
|
||||
elements.approvalsList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSchedules() {
|
||||
const response = await api.request("/schedules");
|
||||
elements.schedulesList.innerHTML = "";
|
||||
if (!response.ok) {
|
||||
elements.schedulesList.innerHTML = `<div class="tiny err">Schedules fetch failed: ${response.error || "unknown"}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const schedules = response.data?.schedules || [];
|
||||
if (!schedules.length) {
|
||||
elements.schedulesList.innerHTML = '<div class="tiny">No schedules configured.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
schedules.forEach((schedule) => {
|
||||
const item = root.createElement("div");
|
||||
item.className = "item";
|
||||
item.innerHTML = `
|
||||
<div><b>${schedule.name || schedule.schedule_id}</b></div>
|
||||
<div class="tiny">id=${schedule.schedule_id} enabled=${Boolean(schedule.enabled)} trigger=${schedule.trigger_type || "n/a"}</div>
|
||||
<div class="tiny">template=${schedule.template_id || "n/a"}</div>
|
||||
`;
|
||||
const bar = root.createElement("div");
|
||||
bar.className = "tools";
|
||||
|
||||
const toggleButton = root.createElement("button");
|
||||
toggleButton.className = "subtle";
|
||||
toggleButton.textContent = "Toggle";
|
||||
toggleButton.onclick = async () => {
|
||||
const result = await api.request(`/schedules/${encodeURIComponent(schedule.schedule_id)}/toggle`, {
|
||||
method: "POST",
|
||||
});
|
||||
appendEvent({ event_type: "schedule_toggle", schedule_id: schedule.schedule_id, ok: result.ok, detail: result.data || result.error });
|
||||
await refreshSchedules();
|
||||
await loadDashboard();
|
||||
};
|
||||
|
||||
const runButton = root.createElement("button");
|
||||
runButton.textContent = "Run Now";
|
||||
runButton.onclick = async () => {
|
||||
const result = await api.request(`/schedules/${encodeURIComponent(schedule.schedule_id)}/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
appendEvent({ event_type: "schedule_run", schedule_id: schedule.schedule_id, ok: result.ok, detail: result.data || result.error });
|
||||
await refreshRuns();
|
||||
};
|
||||
|
||||
bar.appendChild(toggleButton);
|
||||
bar.appendChild(runButton);
|
||||
item.appendChild(bar);
|
||||
elements.schedulesList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function fireTrigger() {
|
||||
setStatus(elements.trigStatus, "Submitting trigger...", "warn-txt");
|
||||
const templateId = elements.trigTemplate.value.trim();
|
||||
if (!templateId) {
|
||||
setStatus(elements.trigStatus, "template_id is required", "err");
|
||||
return;
|
||||
}
|
||||
|
||||
let inputs = {};
|
||||
if (elements.trigInputs.value.trim()) {
|
||||
try {
|
||||
inputs = JSON.parse(elements.trigInputs.value);
|
||||
} catch (error) {
|
||||
setStatus(elements.trigStatus, `inputs JSON parse error: ${String(error)}`, "err");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const requireApprovalRaw = elements.trigApproval.value.trim().toLowerCase();
|
||||
const requireApproval = requireApprovalRaw === "true" || requireApprovalRaw === "1";
|
||||
const response = await api.request("/triggers/fire", {
|
||||
method: "POST",
|
||||
body: {
|
||||
template_id: templateId,
|
||||
inputs,
|
||||
require_approval: requireApproval,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
setStatus(elements.trigStatus, `Trigger failed: ${response.error || "unknown"}`, "err");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(elements.trigStatus, "Trigger accepted", "ok");
|
||||
appendEvent({ event_type: "trigger_fire", detail: response.data });
|
||||
await refreshApprovals();
|
||||
await refreshRuns();
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
const response = await api.request("/config");
|
||||
if (!response.ok) {
|
||||
setStatus(elements.cfgStatus, `Config read failed: ${response.error || "unknown"}`, "err");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = response.data?.config || {};
|
||||
elements.cfgProvider.value = config.provider || "";
|
||||
elements.cfgModel.value = config.model || "";
|
||||
elements.cfgBase.value = config.base_url || "";
|
||||
elements.cfgTimeout.value = config.timeout_sec != null ? String(config.timeout_sec) : "";
|
||||
elements.cfgRetries.value = config.max_retries != null ? String(config.max_retries) : "";
|
||||
setStatus(elements.cfgStatus, "Config loaded", "ok");
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const response = await api.request("/config", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
provider: elements.cfgProvider.value.trim(),
|
||||
model: elements.cfgModel.value.trim(),
|
||||
base_url: elements.cfgBase.value.trim(),
|
||||
timeout_sec: Number(elements.cfgTimeout.value || "0") || 120,
|
||||
max_retries: Number(elements.cfgRetries.value || "0") || 0,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
setStatus(elements.cfgStatus, `Config save failed: ${response.error || "unknown"}`, "err");
|
||||
fillBox(elements.quickBox, response.data || response);
|
||||
return;
|
||||
}
|
||||
setStatus(elements.cfgStatus, "Config saved", "ok");
|
||||
await loadDashboard();
|
||||
}
|
||||
|
||||
async function refreshDoctor() {
|
||||
const [doctorRes, inventoryRes] = await Promise.all([
|
||||
api.request("/security/doctor"),
|
||||
api.request("/preflight/inventory"),
|
||||
]);
|
||||
fillBox(elements.doctorBox, doctorRes.ok ? doctorRes.data : { error: doctorRes.error, status: doctorRes.status, data: doctorRes.data });
|
||||
fillBox(elements.inventoryBox, inventoryRes.ok ? inventoryRes.data : { error: inventoryRes.error, status: inventoryRes.status, data: inventoryRes.data });
|
||||
}
|
||||
|
||||
async function qaRetry() {
|
||||
const runsRes = await api.request("/runs?status=failed&limit=1");
|
||||
if (!runsRes.ok) {
|
||||
fillBox(elements.quickBox, { action: "retry_failed", ok: false, error: runsRes.error, detail: runsRes.data });
|
||||
return;
|
||||
}
|
||||
|
||||
const runs = runsRes.data?.runs || [];
|
||||
if (!runs.length) {
|
||||
fillBox(elements.quickBox, { action: "retry_failed", ok: false, error: "no_failed_run_found" });
|
||||
return;
|
||||
}
|
||||
const run = runs[0];
|
||||
if (!run.schedule_id) {
|
||||
fillBox(elements.quickBox, { action: "retry_failed", ok: false, error: "failed_run_has_no_schedule_id", run });
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.request(`/schedules/${encodeURIComponent(run.schedule_id)}/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
fillBox(elements.quickBox, {
|
||||
action: "retry_failed",
|
||||
ok: response.ok,
|
||||
target_schedule: run.schedule_id,
|
||||
detail: response.data || response.error,
|
||||
});
|
||||
await refreshRuns();
|
||||
}
|
||||
|
||||
async function qaModels() {
|
||||
const provider = elements.cfgProvider.value.trim();
|
||||
const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : "";
|
||||
const response = await api.request(`/llm/models${suffix}`);
|
||||
fillBox(elements.quickBox, { action: "refresh_models", ok: response.ok, detail: response.data || response.error });
|
||||
}
|
||||
|
||||
async function qaDrill() {
|
||||
if (!view.confirm("Run drill via Tools API? This is an admin action.")) return;
|
||||
const listResponse = await api.request("/tools");
|
||||
if (!listResponse.ok) {
|
||||
fillBox(elements.quickBox, { action: "run_drill", ok: false, error: listResponse.error, detail: listResponse.data });
|
||||
return;
|
||||
}
|
||||
const tool = (listResponse.data?.tools || []).find((item) => /drill|crypto/i.test(String(item.name || "")));
|
||||
if (!tool) {
|
||||
fillBox(elements.quickBox, {
|
||||
action: "run_drill",
|
||||
ok: false,
|
||||
error: "no_drill_tool_found",
|
||||
tools: (listResponse.data?.tools || []).map((item) => item.name),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await api.request(`/tools/${encodeURIComponent(tool.name)}/run`, {
|
||||
method: "POST",
|
||||
body: { args: { scenarios: "planned_rotation,token_compromise" } },
|
||||
});
|
||||
fillBox(elements.quickBox, {
|
||||
action: "run_drill",
|
||||
tool: tool.name,
|
||||
ok: response.ok,
|
||||
detail: response.data || response.error,
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
loadDashboard(),
|
||||
refreshRuns(),
|
||||
refreshApprovals(),
|
||||
refreshSchedules(),
|
||||
loadConfig(),
|
||||
refreshDoctor(),
|
||||
]);
|
||||
}
|
||||
|
||||
elements.saveToken.onclick = () => {
|
||||
api.setToken(elements.token.value);
|
||||
setStatus(elements.globalStatus, "Token saved locally in this browser", "ok");
|
||||
};
|
||||
elements.clearToken.onclick = () => {
|
||||
api.clearToken();
|
||||
elements.token.value = "";
|
||||
setStatus(elements.globalStatus, "Token cleared", "warn-txt");
|
||||
};
|
||||
elements.refreshAll.onclick = refreshAll;
|
||||
elements.refreshRuns.onclick = refreshRuns;
|
||||
elements.connectSse.onclick = connectSse;
|
||||
elements.disconnectSse.onclick = disconnectSse;
|
||||
elements.refreshApprovals.onclick = refreshApprovals;
|
||||
elements.refreshSchedules.onclick = refreshSchedules;
|
||||
elements.fireTrigger.onclick = fireTrigger;
|
||||
elements.loadCfg.onclick = loadConfig;
|
||||
elements.saveCfg.onclick = saveConfig;
|
||||
elements.refreshDoctor.onclick = refreshDoctor;
|
||||
elements.qaRetry.onclick = qaRetry;
|
||||
elements.qaModels.onclick = qaModels;
|
||||
elements.qaDrill.onclick = qaDrill;
|
||||
view.addEventListener("beforeunload", disconnectSse);
|
||||
|
||||
refreshAll();
|
||||
|
||||
return {
|
||||
api,
|
||||
disconnectSse,
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { openclawNotifications } from "./openclaw_notifications.js";
|
||||
|
||||
export class OpenClawBannerManager {
|
||||
constructor({ notifications = openclawNotifications, onAction = null } = {}) {
|
||||
this.notifications = notifications;
|
||||
this.onAction = onAction;
|
||||
this.container = null;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
bind(container, onAction) {
|
||||
this.container = container;
|
||||
this.onAction = typeof onAction === "function" ? onAction : this.onAction;
|
||||
}
|
||||
|
||||
showBanner(banner) {
|
||||
if (!this.container) return;
|
||||
|
||||
let normalized = banner;
|
||||
if (arguments.length > 1 && typeof arguments[0] === "string") {
|
||||
normalized = {
|
||||
severity: arguments[0],
|
||||
message: arguments[1],
|
||||
id: `legacy_${Date.now()}`,
|
||||
ttl_ms: 5000,
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
severity,
|
||||
message,
|
||||
ttl_ms,
|
||||
action,
|
||||
dismissible = true,
|
||||
} = normalized;
|
||||
|
||||
const currentBanner = this.container.querySelector(".openclaw-banner");
|
||||
if (currentBanner) {
|
||||
const currentSeverity = currentBanner.dataset.severity;
|
||||
const sameId = currentBanner.dataset.id === id;
|
||||
const isCurrentError = currentSeverity === "error";
|
||||
const isNewError = severity === "error";
|
||||
if (isCurrentError && !isNewError && !sameId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
let bannerEl = currentBanner;
|
||||
if (!bannerEl) {
|
||||
bannerEl = document.createElement("div");
|
||||
const header = this.container.querySelector(".openclaw-header");
|
||||
if (!header) return;
|
||||
header.after(bannerEl);
|
||||
}
|
||||
|
||||
bannerEl.className = `openclaw-banner openclaw-banner-${severity}`;
|
||||
bannerEl.dataset.id = id;
|
||||
bannerEl.dataset.severity = severity;
|
||||
bannerEl.innerHTML = "";
|
||||
|
||||
const messageNode = document.createElement("span");
|
||||
messageNode.textContent = message;
|
||||
bannerEl.appendChild(messageNode);
|
||||
|
||||
if (action) {
|
||||
const button = document.createElement("button");
|
||||
button.className = "openclaw-banner-action";
|
||||
button.textContent = action.label;
|
||||
button.addEventListener("click", () => {
|
||||
if (this.onAction) this.onAction(action);
|
||||
});
|
||||
bannerEl.appendChild(button);
|
||||
}
|
||||
|
||||
if (dismissible) {
|
||||
const close = document.createElement("button");
|
||||
close.className = "openclaw-banner-close";
|
||||
close.textContent = "\u00D7";
|
||||
close.addEventListener("click", () => {
|
||||
bannerEl.remove();
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
});
|
||||
bannerEl.appendChild(close);
|
||||
}
|
||||
|
||||
if (ttl_ms > 0) {
|
||||
this.timer = setTimeout(() => {
|
||||
if (bannerEl.isConnected) bannerEl.remove();
|
||||
}, ttl_ms);
|
||||
}
|
||||
|
||||
const shouldPersist = normalized.persist != null
|
||||
? Boolean(normalized.persist)
|
||||
: severity === "warning" || severity === "error";
|
||||
if (shouldPersist) {
|
||||
this.notifications.notify({
|
||||
id: `banner_${id || severity}`,
|
||||
severity,
|
||||
message,
|
||||
source: normalized.source || "banner",
|
||||
dedupeKey: `banner:${id || `${severity}:${message}`}`,
|
||||
action,
|
||||
metadata: {
|
||||
ttl_ms: ttl_ms || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { openclawNotifications } from "./openclaw_notifications.js";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function formatNotificationTime(value) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawNotificationCenter {
|
||||
constructor({ notifications = openclawNotifications, onAction = null } = {}) {
|
||||
this.notifications = notifications;
|
||||
this.onAction = onAction;
|
||||
this.nodes = null;
|
||||
this.notificationsOpen = false;
|
||||
this.snapshot = [];
|
||||
this.unsubscribe = this.notifications.subscribe((entries) => {
|
||||
this.snapshot = Array.isArray(entries) ? entries : [];
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
setActionHandler(handler) {
|
||||
this.onAction = typeof handler === "function" ? handler : null;
|
||||
}
|
||||
|
||||
buildToggle() {
|
||||
const button = document.createElement("button");
|
||||
button.className = "openclaw-notification-toggle";
|
||||
button.id = "openclaw-notification-toggle";
|
||||
button.type = "button";
|
||||
button.innerHTML = `
|
||||
<span class="openclaw-notification-toggle-label">Alerts</span>
|
||||
<span class="openclaw-notification-badge" hidden>0</span>
|
||||
`;
|
||||
button.addEventListener("click", () => this.toggle());
|
||||
|
||||
this.nodes = this.nodes || {};
|
||||
this.nodes.toggle = button;
|
||||
this.nodes.badge = button.querySelector(".openclaw-notification-badge");
|
||||
return button;
|
||||
}
|
||||
|
||||
buildPanel() {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "openclaw-notification-panel";
|
||||
panel.id = "openclaw-notification-panel";
|
||||
panel.hidden = true;
|
||||
panel.innerHTML = `
|
||||
<div class="openclaw-notification-panel-header">
|
||||
<div>
|
||||
<div class="openclaw-notification-panel-title">Notification Center</div>
|
||||
<div class="openclaw-notification-panel-subtitle">Persistent operator alerts and actions</div>
|
||||
</div>
|
||||
<button type="button" class="openclaw-notification-close" aria-label="Close notification center">x</button>
|
||||
</div>
|
||||
<div class="openclaw-notification-list"></div>
|
||||
`;
|
||||
|
||||
panel.querySelector(".openclaw-notification-close").addEventListener("click", () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
panel.querySelector(".openclaw-notification-list").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-notification-action]");
|
||||
if (!button) return;
|
||||
const action = button.getAttribute("data-notification-action");
|
||||
const id = button.getAttribute("data-notification-id");
|
||||
if (!id) return;
|
||||
|
||||
if (action === "ack") {
|
||||
this.notifications.acknowledge(id);
|
||||
return;
|
||||
}
|
||||
if (action === "dismiss") {
|
||||
this.notifications.dismiss(id);
|
||||
return;
|
||||
}
|
||||
if (action === "open") {
|
||||
const entry = this.snapshot.find((item) => item.id === id);
|
||||
if (!entry?.action || !this.onAction) return;
|
||||
this.notifications.acknowledge(id);
|
||||
this.onAction(entry.action);
|
||||
}
|
||||
});
|
||||
|
||||
this.nodes = this.nodes || {};
|
||||
this.nodes.panel = panel;
|
||||
this.nodes.list = panel.querySelector(".openclaw-notification-list");
|
||||
return panel;
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.notificationsOpen = !this.notificationsOpen;
|
||||
this.render();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.notificationsOpen = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const badge = this.nodes?.badge;
|
||||
const panel = this.nodes?.panel;
|
||||
const list = this.nodes?.list;
|
||||
if (!badge || !panel || !list) return;
|
||||
|
||||
const activeEntries = this.snapshot.filter((entry) => !entry.dismissed_at);
|
||||
const unreadCount = activeEntries.filter((entry) => !entry.acknowledged_at).length;
|
||||
badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
badge.hidden = unreadCount <= 0;
|
||||
panel.hidden = !this.notificationsOpen;
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
list.innerHTML = '<div class="openclaw-notification-empty">No active operator notifications.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = activeEntries.map((entry) => {
|
||||
const escapedId = escapeHtml(entry.id);
|
||||
const escapedMessage = escapeHtml(entry.message || "");
|
||||
const escapedSource = escapeHtml(entry.source || "system");
|
||||
const escapedSeverity = escapeHtml(entry.severity || "info");
|
||||
const escapedActionLabel = escapeHtml(entry.action?.label || "Open");
|
||||
const countHtml = entry.count > 1
|
||||
? `<span class="openclaw-notification-count">x${escapeHtml(entry.count)}</span>`
|
||||
: "";
|
||||
const actionHtml = entry.action?.type && entry.action?.payload
|
||||
? `<button type="button" class="openclaw-btn openclaw-btn-sm" data-notification-action="open" data-notification-id="${escapedId}" aria-label="Open notification action for ${escapedMessage}">${escapedActionLabel}</button>`
|
||||
: "";
|
||||
const ackLabel = entry.acknowledged_at ? "Acknowledged" : "Acknowledge";
|
||||
const ackDisabled = entry.acknowledged_at ? "disabled" : "";
|
||||
|
||||
return `
|
||||
<div class="openclaw-notification-item openclaw-notification-${escapedSeverity}">
|
||||
<div class="openclaw-notification-meta">
|
||||
<span class="openclaw-notification-source">${escapedSource}</span>
|
||||
<span class="openclaw-notification-time">${escapeHtml(formatNotificationTime(entry.updated_at))}</span>
|
||||
</div>
|
||||
<div class="openclaw-notification-message">${escapedMessage}</div>
|
||||
<div class="openclaw-notification-footer">
|
||||
<div class="openclaw-notification-state">
|
||||
<span class="openclaw-notification-severity">${escapedSeverity}</span>
|
||||
${countHtml}
|
||||
</div>
|
||||
<div class="openclaw-notification-actions">
|
||||
${actionHtml}
|
||||
<button type="button" class="openclaw-btn openclaw-btn-sm" data-notification-action="ack" data-notification-id="${escapedId}" aria-label="Acknowledge notification: ${escapedMessage}" ${ackDisabled}>${ackLabel}</button>
|
||||
<button type="button" class="openclaw-btn openclaw-btn-sm openclaw-btn-danger" data-notification-action="dismiss" data-notification-id="${escapedId}" aria-label="Dismiss notification: ${escapedMessage}">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (typeof this.unsubscribe === "function") {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-287
@@ -8,7 +8,8 @@ import { openclawApi } from "./openclaw_api.js";
|
||||
import { normalizeLegacyClassNames } from "./openclaw_utils.js";
|
||||
import { OpenClawActions } from "./openclaw_actions.js";
|
||||
import { QueueMonitor } from "./openclaw_queue_monitor.js";
|
||||
import { openclawNotifications } from "./openclaw_notifications.js";
|
||||
import { OpenClawNotificationCenter } from "./openclaw_notification_center.js";
|
||||
import { OpenClawBannerManager } from "./openclaw_banner_manager.js";
|
||||
|
||||
export class OpenClawUI {
|
||||
constructor() {
|
||||
@@ -18,12 +19,11 @@ export class OpenClawUI {
|
||||
panel: null,
|
||||
content: null,
|
||||
};
|
||||
this.notificationsOpen = false;
|
||||
this.notificationNodes = null;
|
||||
this._notificationSnapshot = [];
|
||||
this._unsubscribeNotifications = openclawNotifications.subscribe((entries) => {
|
||||
this._notificationSnapshot = entries;
|
||||
this._renderNotificationSnapshot();
|
||||
this.notificationCenter = new OpenClawNotificationCenter({
|
||||
onAction: (action) => this.handleAction(action),
|
||||
});
|
||||
this.bannerManager = new OpenClawBannerManager({
|
||||
onAction: (action) => this.handleAction(action),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ export class OpenClawUI {
|
||||
container.innerHTML = "";
|
||||
container.className = "openclaw-sidebar-container";
|
||||
|
||||
// 1. Header
|
||||
const header = document.createElement("div");
|
||||
header.className = "openclaw-header";
|
||||
|
||||
@@ -124,37 +123,36 @@ export class OpenClawUI {
|
||||
title.className = "openclaw-title";
|
||||
title.textContent = "OpenClaw";
|
||||
|
||||
// F9: About badges (version fetched from /openclaw/health; legacy /moltbot/health)
|
||||
const badges = document.createElement("div");
|
||||
badges.className = "openclaw-badges";
|
||||
|
||||
const versionSpan = document.createElement("span");
|
||||
versionSpan.className = "openclaw-version";
|
||||
versionSpan.textContent = "v...";
|
||||
|
||||
const repoLink = document.createElement("a");
|
||||
repoLink.href = "https://github.com/rookiestar28/ComfyUI-OpenClaw";
|
||||
repoLink.target = "_blank";
|
||||
repoLink.className = "openclaw-repo-link";
|
||||
repoLink.title = "View on GitHub";
|
||||
repoLink.textContent = "View on GitHub";
|
||||
|
||||
badges.appendChild(versionSpan);
|
||||
badges.appendChild(repoLink);
|
||||
badges.appendChild(this._buildNotificationToggle());
|
||||
badges.appendChild(this.notificationCenter.buildToggle());
|
||||
|
||||
// Fetch version from health endpoint
|
||||
openclawApi.getHealth().then(res => {
|
||||
openclawApi.getHealth().then((res) => {
|
||||
if (res.ok && res.data) {
|
||||
const data = res.data;
|
||||
if (data.pack) {
|
||||
versionSpan.textContent = `v${data.pack.version}`;
|
||||
}
|
||||
|
||||
// F55: Control plane mode indicator badge
|
||||
const cpMode = data?.control_plane?.mode || data?.deployment_profile || "local";
|
||||
const modeBadge = document.createElement("span");
|
||||
modeBadge.className = `openclaw-mode-badge openclaw-mode-${cpMode}`;
|
||||
modeBadge.textContent = cpMode.toUpperCase();
|
||||
modeBadge.title = `Control plane: ${cpMode}`;
|
||||
// Style inline for immediate visibility
|
||||
modeBadge.style.cssText = `
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px;
|
||||
font-weight: 600; margin-left: 6px; letter-spacing: 0.5px;
|
||||
@@ -172,14 +170,14 @@ export class OpenClawUI {
|
||||
badges.appendChild(modeBadge);
|
||||
this._controlPlaneMode = cpMode;
|
||||
|
||||
// S15: Check exposure
|
||||
this.checkExposure(data?.access_policy);
|
||||
|
||||
// R87: Check Backpressure
|
||||
const obs = data.stats?.observability;
|
||||
if (obs && obs.total_dropped > 0) {
|
||||
const dropCount = obs.total_dropped;
|
||||
this.showBanner("warning", `\u26A0\uFE0F High load: ${dropCount} observability events dropped (Queue full). logs/traces might be incomplete.`);
|
||||
this.showBanner(
|
||||
"warning",
|
||||
`\u26A0\uFE0F High load: ${obs.total_dropped} observability events dropped (Queue full). logs/traces might be incomplete.`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
versionSpan.textContent = "v?.?.?";
|
||||
@@ -192,158 +190,26 @@ export class OpenClawUI {
|
||||
header.appendChild(title);
|
||||
header.appendChild(badges);
|
||||
container.appendChild(header);
|
||||
container.appendChild(this._buildNotificationPanel());
|
||||
container.appendChild(this.notificationCenter.buildPanel());
|
||||
|
||||
// 2. Tab Bar
|
||||
const tabBar = document.createElement("div");
|
||||
tabBar.className = "openclaw-tabs";
|
||||
this.tabBar = tabBar;
|
||||
container.appendChild(tabBar);
|
||||
|
||||
// 3. Content Area
|
||||
const contentArea = document.createElement("div");
|
||||
contentArea.className = "openclaw-content";
|
||||
this.contentArea = contentArea;
|
||||
container.appendChild(contentArea);
|
||||
|
||||
// Initialize Tabs
|
||||
tabManager.init(tabBar, contentArea);
|
||||
normalizeLegacyClassNames(container);
|
||||
this._renderNotificationSnapshot();
|
||||
}
|
||||
|
||||
_buildNotificationToggle() {
|
||||
const button = document.createElement("button");
|
||||
button.className = "openclaw-notification-toggle";
|
||||
button.id = "openclaw-notification-toggle";
|
||||
button.type = "button";
|
||||
button.innerHTML = `
|
||||
<span class="openclaw-notification-toggle-label">Alerts</span>
|
||||
<span class="openclaw-notification-badge" hidden>0</span>
|
||||
`;
|
||||
button.addEventListener("click", () => this.toggleNotifications());
|
||||
this.notificationNodes = this.notificationNodes || {};
|
||||
this.notificationNodes.toggle = button;
|
||||
this.notificationNodes.badge = button.querySelector(".openclaw-notification-badge");
|
||||
return button;
|
||||
}
|
||||
|
||||
_buildNotificationPanel() {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "openclaw-notification-panel";
|
||||
panel.id = "openclaw-notification-panel";
|
||||
panel.hidden = true;
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="openclaw-notification-panel-header">
|
||||
<div>
|
||||
<div class="openclaw-notification-panel-title">Notification Center</div>
|
||||
<div class="openclaw-notification-panel-subtitle">Persistent operator alerts and actions</div>
|
||||
</div>
|
||||
<button type="button" class="openclaw-notification-close" aria-label="Close notification center">x</button>
|
||||
</div>
|
||||
<div class="openclaw-notification-list"></div>
|
||||
`;
|
||||
|
||||
panel.querySelector(".openclaw-notification-close").addEventListener("click", () => {
|
||||
this.notificationsOpen = false;
|
||||
this._renderNotificationSnapshot();
|
||||
});
|
||||
|
||||
panel.querySelector(".openclaw-notification-list").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-notification-action]");
|
||||
if (!button) return;
|
||||
const action = button.getAttribute("data-notification-action");
|
||||
const id = button.getAttribute("data-notification-id");
|
||||
if (!id) return;
|
||||
|
||||
if (action === "ack") {
|
||||
openclawNotifications.acknowledge(id);
|
||||
return;
|
||||
}
|
||||
if (action === "dismiss") {
|
||||
openclawNotifications.dismiss(id);
|
||||
return;
|
||||
}
|
||||
if (action === "open") {
|
||||
const entry = this._notificationSnapshot.find((item) => item.id === id);
|
||||
if (!entry?.action) return;
|
||||
openclawNotifications.acknowledge(id);
|
||||
this.handleAction(entry.action);
|
||||
}
|
||||
});
|
||||
|
||||
this.notificationNodes = this.notificationNodes || {};
|
||||
this.notificationNodes.panel = panel;
|
||||
this.notificationNodes.list = panel.querySelector(".openclaw-notification-list");
|
||||
return panel;
|
||||
this.bannerManager.bind(container, (action) => this.handleAction(action));
|
||||
this.notificationCenter.render();
|
||||
}
|
||||
|
||||
toggleNotifications() {
|
||||
this.notificationsOpen = !this.notificationsOpen;
|
||||
this._renderNotificationSnapshot();
|
||||
}
|
||||
|
||||
_formatNotificationTime(value) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
_renderNotificationSnapshot() {
|
||||
const badge = this.notificationNodes?.badge;
|
||||
const panel = this.notificationNodes?.panel;
|
||||
const list = this.notificationNodes?.list;
|
||||
if (!badge || !panel || !list) return;
|
||||
|
||||
const activeEntries = this._notificationSnapshot.filter((entry) => !entry.dismissed_at);
|
||||
const unreadCount = activeEntries.filter((entry) => !entry.acknowledged_at).length;
|
||||
badge.textContent = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
badge.hidden = unreadCount <= 0;
|
||||
panel.hidden = !this.notificationsOpen;
|
||||
|
||||
if (activeEntries.length === 0) {
|
||||
list.innerHTML = '<div class="openclaw-notification-empty">No active operator notifications.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = activeEntries.map((entry) => {
|
||||
const escapedMessage = String(entry.message || "").replace(/"/g, """);
|
||||
const actionHtml = entry.action?.type && entry.action?.payload
|
||||
? `<button type="button" class="openclaw-btn openclaw-btn-sm" data-notification-action="open" data-notification-id="${entry.id}" aria-label="Open notification action for ${escapedMessage}">${entry.action.label || "Open"}</button>`
|
||||
: "";
|
||||
const countHtml = entry.count > 1
|
||||
? `<span class="openclaw-notification-count">x${entry.count}</span>`
|
||||
: "";
|
||||
const ackLabel = entry.acknowledged_at ? "Acknowledged" : "Acknowledge";
|
||||
const ackDisabled = entry.acknowledged_at ? "disabled" : "";
|
||||
return `
|
||||
<div class="openclaw-notification-item openclaw-notification-${entry.severity}">
|
||||
<div class="openclaw-notification-meta">
|
||||
<span class="openclaw-notification-source">${entry.source}</span>
|
||||
<span class="openclaw-notification-time">${this._formatNotificationTime(entry.updated_at)}</span>
|
||||
</div>
|
||||
<div class="openclaw-notification-message">${entry.message}</div>
|
||||
<div class="openclaw-notification-footer">
|
||||
<div class="openclaw-notification-state">
|
||||
<span class="openclaw-notification-severity">${entry.severity}</span>
|
||||
${countHtml}
|
||||
</div>
|
||||
<div class="openclaw-notification-actions">
|
||||
${actionHtml}
|
||||
<button type="button" class="openclaw-btn openclaw-btn-sm" data-notification-action="ack" data-notification-id="${entry.id}" aria-label="Acknowledge notification: ${escapedMessage}" ${ackDisabled}>${ackLabel}</button>
|
||||
<button type="button" class="openclaw-btn openclaw-btn-sm openclaw-btn-danger" data-notification-action="dismiss" data-notification-id="${entry.id}" aria-label="Dismiss notification: ${escapedMessage}">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
this.notificationCenter.toggle();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,18 +219,13 @@ export class OpenClawUI {
|
||||
if (!policy) return;
|
||||
|
||||
const isLocal = ["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
|
||||
|
||||
// Warn if not local
|
||||
if (!isLocal) {
|
||||
const isProtected = policy.observability === "token" && policy.token_configured;
|
||||
|
||||
if (!isProtected) {
|
||||
// High risk: Remote + No Token
|
||||
this.showBanner("warning", "\u26A0\uFE0F Remote access detected; logs/config are protected unless you explicitly enable token-based access.");
|
||||
} else {
|
||||
// Medium risk: Remote + Token (Just info)
|
||||
// Optionally show nothing, or a small "Remote Access Secured" badge
|
||||
// console.log("OpenClaw remote access secured by token.");
|
||||
this.showBanner(
|
||||
"warning",
|
||||
"\u26A0\uFE0F Remote access detected; logs/config are protected unless you explicitly enable token-based access."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,134 +235,27 @@ export class OpenClawUI {
|
||||
* @param {Object} banner - { id, severity, message, source, ttl_ms, dismissible, action }
|
||||
*/
|
||||
showBanner(banner) {
|
||||
// If passed raw args (legacy compatibility)
|
||||
if (arguments.length > 1 && typeof arguments[0] === "string") {
|
||||
banner = {
|
||||
severity: arguments[0],
|
||||
message: arguments[1],
|
||||
id: "legacy_" + Date.now(),
|
||||
ttl_ms: 5000
|
||||
};
|
||||
}
|
||||
|
||||
const { id, severity, message, ttl_ms, action, dismissible = true } = banner;
|
||||
|
||||
// 1. Priority Check
|
||||
// If an error is currently shown, don't replace with info/warning unless it's a new error
|
||||
const currentBanner = this.container.querySelector('.openclaw-banner');
|
||||
if (currentBanner) {
|
||||
const currentSeverity = currentBanner.dataset.severity;
|
||||
const isCurrentError = currentSeverity === "error";
|
||||
const isNewError = severity === "error";
|
||||
|
||||
// If current is error and new is not, ignore new (unless current is stale? handled by TTL)
|
||||
// Exception: update content if same ID
|
||||
const sameId = currentBanner.dataset.id === id;
|
||||
if (isCurrentError && !isNewError && !sameId) {
|
||||
return; // Suppress lower priority
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clear existing timer
|
||||
if (this._bannerTimer) {
|
||||
clearTimeout(this._bannerTimer);
|
||||
this._bannerTimer = null;
|
||||
}
|
||||
|
||||
// 3. Render
|
||||
let bannerEl = currentBanner; // Reuse or create
|
||||
if (!bannerEl) {
|
||||
bannerEl = document.createElement("div");
|
||||
// Insert after header
|
||||
const header = this.container.querySelector('.openclaw-header');
|
||||
header.after(bannerEl);
|
||||
}
|
||||
|
||||
bannerEl.className = `openclaw-banner openclaw-banner-${severity}`;
|
||||
bannerEl.dataset.id = id;
|
||||
bannerEl.dataset.severity = severity;
|
||||
bannerEl.innerHTML = ""; // Clear content
|
||||
|
||||
// Message
|
||||
const msgSpan = document.createElement("span");
|
||||
msgSpan.textContent = message;
|
||||
bannerEl.appendChild(msgSpan);
|
||||
|
||||
// Action Button
|
||||
if (action) {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "openclaw-banner-action";
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener("click", () => this.handleAction(action));
|
||||
bannerEl.appendChild(btn);
|
||||
}
|
||||
|
||||
// Dismiss Button
|
||||
if (dismissible) {
|
||||
const close = document.createElement("button");
|
||||
close.className = "openclaw-banner-close";
|
||||
close.textContent = "\u00D7";
|
||||
close.addEventListener("click", () => {
|
||||
bannerEl.remove();
|
||||
if (this._bannerTimer) clearTimeout(this._bannerTimer);
|
||||
});
|
||||
bannerEl.appendChild(close);
|
||||
}
|
||||
|
||||
// 4. TTL / Auto-dismiss
|
||||
if (ttl_ms > 0) {
|
||||
this._bannerTimer = setTimeout(() => {
|
||||
if (bannerEl.isConnected) bannerEl.remove();
|
||||
}, ttl_ms);
|
||||
}
|
||||
|
||||
const shouldPersist = banner.persist != null
|
||||
? Boolean(banner.persist)
|
||||
: severity === "warning" || severity === "error";
|
||||
if (shouldPersist) {
|
||||
openclawNotifications.notify({
|
||||
id: `banner_${id || severity}`,
|
||||
severity,
|
||||
message,
|
||||
source: banner.source || "banner",
|
||||
dedupeKey: `banner:${id || `${severity}:${message}`}`,
|
||||
action,
|
||||
metadata: {
|
||||
ttl_ms: ttl_ms || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
this.bannerManager.showBanner(...arguments);
|
||||
}
|
||||
|
||||
handleAction(action) {
|
||||
if (!action) return;
|
||||
|
||||
const run = () => {
|
||||
switch (action.type) {
|
||||
case "url":
|
||||
window.open(action.payload, "_blank");
|
||||
break;
|
||||
case "tab":
|
||||
tabManager.activateTab(action.payload);
|
||||
break;
|
||||
case "action":
|
||||
// F51: Route through OpenClaw actions singleton.
|
||||
if (openclawActions && openclawActions.dispatch) {
|
||||
openclawActions.dispatch(action.payload);
|
||||
} else {
|
||||
console.log("Action triggered:", action.payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// F51: Check if action requires confirmation (heuristic or explicit)
|
||||
// For now, only explicit 'confirm' property in action banner handles this,
|
||||
// OR if the action type itself implies mutation.
|
||||
// But Banner actions are usually just navigation.
|
||||
// Use showConfirm if the banner action metadata says so?
|
||||
// Let's assume standard banner actions are safe unless specified.
|
||||
run();
|
||||
switch (action.type) {
|
||||
case "url":
|
||||
window.open(action.payload, "_blank");
|
||||
break;
|
||||
case "tab":
|
||||
tabManager.activateTab(action.payload);
|
||||
break;
|
||||
case "action":
|
||||
if (openclawActions && openclawActions.dispatch) {
|
||||
openclawActions.dispatch(action.payload);
|
||||
} else {
|
||||
console.log("Action triggered:", action.payload);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -509,7 +263,6 @@ export class OpenClawUI {
|
||||
* @param {Object} options - { title, message, fatal, onConfirm }
|
||||
*/
|
||||
showConfirm({ title, message, fatal = false, onConfirm }) {
|
||||
// Create modal overlay
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "openclaw-modal-overlay";
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRemoteAdminApi, parseSseChunk } from "../../admin_console_api.js";
|
||||
|
||||
describe("admin_console_api", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("stores the remote admin token and falls back from canonical to legacy paths on 404", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("{}", { status: 404, headers: { "Content-Type": "application/json" } }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, value: "legacy" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}));
|
||||
|
||||
const api = createRemoteAdminApi({
|
||||
fetchImpl: fetchMock,
|
||||
storage: localStorage,
|
||||
});
|
||||
|
||||
api.setToken("secret-token");
|
||||
const response = await api.request("/health");
|
||||
|
||||
expect(api.getToken()).toBe("secret-token");
|
||||
expect(localStorage.getItem("openclaw_remote_admin_token")).toBe("secret-token");
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.data).toEqual({ ok: true, value: "legacy" });
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/openclaw/health");
|
||||
expect(fetchMock.mock.calls[1][0]).toBe("/moltbot/health");
|
||||
});
|
||||
|
||||
it("parses SSE event chunks into payload objects", () => {
|
||||
const payload = parseSseChunk('event: queued\ndata: {"seq":7,"prompt_id":"abc"}\n\n');
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({
|
||||
event_type: "queued",
|
||||
prompt_id: "abc",
|
||||
seq: 7,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OpenClawBannerManager } from "../../openclaw_banner_manager.js";
|
||||
|
||||
describe("openclaw_banner_manager", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div class="openclaw-sidebar-container">
|
||||
<div class="openclaw-header"></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("persists warning/error banners and routes action callbacks", () => {
|
||||
const notifications = { notify: vi.fn() };
|
||||
const onAction = vi.fn();
|
||||
const manager = new OpenClawBannerManager({ notifications, onAction });
|
||||
const container = document.querySelector(".openclaw-sidebar-container");
|
||||
manager.bind(container, onAction);
|
||||
|
||||
manager.showBanner({
|
||||
id: "bg-1",
|
||||
severity: "error",
|
||||
message: "Backend disconnected",
|
||||
ttl_ms: 0,
|
||||
action: {
|
||||
label: "Open Jobs",
|
||||
type: "tab",
|
||||
payload: "job-monitor",
|
||||
},
|
||||
});
|
||||
|
||||
document.querySelector(".openclaw-banner-action").click();
|
||||
expect(onAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payload: "job-monitor", type: "tab" })
|
||||
);
|
||||
expect(notifications.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: "error",
|
||||
message: "Backend disconnected",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses lower-priority banners while an error banner is active", () => {
|
||||
const notifications = { notify: vi.fn() };
|
||||
const manager = new OpenClawBannerManager({ notifications });
|
||||
const container = document.querySelector(".openclaw-sidebar-container");
|
||||
manager.bind(container);
|
||||
|
||||
manager.showBanner({
|
||||
id: "err-1",
|
||||
severity: "error",
|
||||
message: "Critical",
|
||||
ttl_ms: 0,
|
||||
});
|
||||
manager.showBanner({
|
||||
id: "warn-1",
|
||||
severity: "warning",
|
||||
message: "Should be ignored",
|
||||
ttl_ms: 0,
|
||||
});
|
||||
|
||||
expect(document.querySelector(".openclaw-banner").textContent).toContain("Critical");
|
||||
expect(document.querySelector(".openclaw-banner").textContent).not.toContain("Should be ignored");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OpenClawNotificationCenter } from "../../openclaw_notification_center.js";
|
||||
|
||||
function createNotificationsStub() {
|
||||
const listeners = new Set();
|
||||
const state = {
|
||||
entries: [],
|
||||
acknowledged: [],
|
||||
dismissed: [],
|
||||
};
|
||||
return {
|
||||
state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
listener(state.entries);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
push(entries) {
|
||||
state.entries = entries;
|
||||
listeners.forEach((listener) => listener(entries));
|
||||
},
|
||||
acknowledge(id) {
|
||||
state.acknowledged.push(id);
|
||||
},
|
||||
dismiss(id) {
|
||||
state.dismissed.push(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("openclaw_notification_center", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("renders unread badge and routes dismiss/open actions", () => {
|
||||
const notifications = createNotificationsStub();
|
||||
const onAction = vi.fn();
|
||||
const center = new OpenClawNotificationCenter({ notifications, onAction });
|
||||
document.body.appendChild(center.buildToggle());
|
||||
document.body.appendChild(center.buildPanel());
|
||||
|
||||
notifications.push([
|
||||
{
|
||||
id: "ntf-1",
|
||||
source: "model-manager",
|
||||
severity: "error",
|
||||
message: "search: search_failed",
|
||||
updated_at: "2026-03-20T00:00:00Z",
|
||||
count: 1,
|
||||
acknowledged_at: null,
|
||||
dismissed_at: null,
|
||||
action: {
|
||||
label: "Open Model Manager",
|
||||
type: "tab",
|
||||
payload: "model-manager",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
center.toggle();
|
||||
expect(document.querySelector(".openclaw-notification-badge").textContent).toBe("1");
|
||||
|
||||
document.querySelector('[data-notification-action="open"]').click();
|
||||
expect(notifications.state.acknowledged).toEqual(["ntf-1"]);
|
||||
expect(onAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payload: "model-manager", type: "tab" })
|
||||
);
|
||||
|
||||
document.querySelector('[data-notification-action="dismiss"]').click();
|
||||
expect(notifications.state.dismissed).toEqual(["ntf-1"]);
|
||||
});
|
||||
|
||||
it("escapes notification message content in rendered HTML", () => {
|
||||
const notifications = createNotificationsStub();
|
||||
const center = new OpenClawNotificationCenter({ notifications });
|
||||
document.body.appendChild(center.buildToggle());
|
||||
document.body.appendChild(center.buildPanel());
|
||||
|
||||
notifications.push([
|
||||
{
|
||||
id: "ntf-escape",
|
||||
source: "<source>",
|
||||
severity: "warning",
|
||||
message: '<img src=x onerror="boom">',
|
||||
updated_at: "2026-03-20T00:00:00Z",
|
||||
count: 2,
|
||||
acknowledged_at: null,
|
||||
dismissed_at: null,
|
||||
action: null,
|
||||
},
|
||||
]);
|
||||
|
||||
center.toggle();
|
||||
const messageNode = document.querySelector(".openclaw-notification-message");
|
||||
expect(messageNode.innerHTML).not.toContain("<img");
|
||||
expect(messageNode.textContent).toContain('<img src=x onerror="boom">');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user