refactor: decompose frontend shell and admin console

This commit is contained in:
rookiestar28
2026-03-20 04:58:25 +08:00
parent 9c50ddc4e9
commit bd318eb0ac
9 changed files with 1222 additions and 329 deletions
+2 -42
View File
@@ -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>
+129
View File
@@ -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, "");
},
};
}
+543
View File
@@ -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,
};
}
+118
View File
@@ -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,
},
});
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import { openclawNotifications } from "./openclaw_notifications.js";
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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
View File
@@ -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, "&quot;");
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";
+45
View File
@@ -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">');
});
});