diff --git a/web/admin_console.html b/web/admin_console.html index 30df641..781ffc1 100644 --- a/web/admin_console.html +++ b/web/admin_console.html @@ -88,49 +88,9 @@ diff --git a/web/admin_console_api.js b/web/admin_console_api.js new file mode 100644 index 0000000..ab75692 --- /dev/null +++ b/web/admin_console_api.js @@ -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, ""); + }, + }; +} diff --git a/web/admin_console_app.js b/web/admin_console_app.js new file mode 100644 index 0000000..369faf7 --- /dev/null +++ b/web/admin_console_app.js @@ -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 = '
No run records.
'; + return; + } + + runs.forEach((run) => { + const node = root.createElement("div"); + node.className = "item"; + node.innerHTML = ` +
${run.run_id || "run"}
+
status=${run.status || "n/a"} schedule=${run.schedule_id || "n/a"}
+
template=${run.template_id || "n/a"} at=${run.started_at || run.created_at || "n/a"}
+ `; + 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 = `
Approvals fetch failed: ${response.error || "unknown"}
`; + return; + } + + const approvals = response.data?.approvals || []; + if (!approvals.length) { + elements.approvalsList.innerHTML = '
No pending approvals.
'; + return; + } + + approvals.forEach((approval) => { + const item = root.createElement("div"); + item.className = "item"; + item.innerHTML = ` +
${approval.approval_id || "approval"}
+
template=${approval.template_id || "n/a"} source=${approval.source || "n/a"}
+ `; + 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 = `
Schedules fetch failed: ${response.error || "unknown"}
`; + return; + } + + const schedules = response.data?.schedules || []; + if (!schedules.length) { + elements.schedulesList.innerHTML = '
No schedules configured.
'; + return; + } + + schedules.forEach((schedule) => { + const item = root.createElement("div"); + item.className = "item"; + item.innerHTML = ` +
${schedule.name || schedule.schedule_id}
+
id=${schedule.schedule_id} enabled=${Boolean(schedule.enabled)} trigger=${schedule.trigger_type || "n/a"}
+
template=${schedule.template_id || "n/a"}
+ `; + 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, + }; +} diff --git a/web/openclaw_banner_manager.js b/web/openclaw_banner_manager.js new file mode 100644 index 0000000..8396341 --- /dev/null +++ b/web/openclaw_banner_manager.js @@ -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, + }, + }); + } + } +} diff --git a/web/openclaw_notification_center.js b/web/openclaw_notification_center.js new file mode 100644 index 0000000..d6050cc --- /dev/null +++ b/web/openclaw_notification_center.js @@ -0,0 +1,178 @@ +import { openclawNotifications } from "./openclaw_notifications.js"; + +function escapeHtml(value) { + return String(value ?? "") + .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 = ` + Alerts + + `; + 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 = ` +
+
+
Notification Center
+
Persistent operator alerts and actions
+
+ +
+
+ `; + + 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 = '
No active operator notifications.
'; + 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 + ? `x${escapeHtml(entry.count)}` + : ""; + const actionHtml = entry.action?.type && entry.action?.payload + ? `` + : ""; + const ackLabel = entry.acknowledged_at ? "Acknowledged" : "Acknowledge"; + const ackDisabled = entry.acknowledged_at ? "disabled" : ""; + + return ` +
+
+ ${escapedSource} + ${escapeHtml(formatNotificationTime(entry.updated_at))} +
+
${escapedMessage}
+ +
+ `; + }).join(""); + } + + dispose() { + if (typeof this.unsubscribe === "function") { + this.unsubscribe(); + this.unsubscribe = null; + } + } +} diff --git a/web/openclaw_ui.js b/web/openclaw_ui.js index a6bc7de..f5de42d 100644 --- a/web/openclaw_ui.js +++ b/web/openclaw_ui.js @@ -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 = ` - Alerts - - `; - 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 = ` -
-
-
Notification Center
-
Persistent operator alerts and actions
-
- -
-
- `; - - 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 = '
No active operator notifications.
'; - return; - } - - list.innerHTML = activeEntries.map((entry) => { - const escapedMessage = String(entry.message || "").replace(/"/g, """); - const actionHtml = entry.action?.type && entry.action?.payload - ? `` - : ""; - const countHtml = entry.count > 1 - ? `x${entry.count}` - : ""; - const ackLabel = entry.acknowledged_at ? "Acknowledged" : "Acknowledge"; - const ackDisabled = entry.acknowledged_at ? "disabled" : ""; - return ` -
-
- ${entry.source} - ${this._formatNotificationTime(entry.updated_at)} -
-
${entry.message}
- -
- `; - }).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"; diff --git a/web/tests/unit/admin_console_api.test.js b/web/tests/unit/admin_console_api.test.js new file mode 100644 index 0000000..fdae643 --- /dev/null +++ b/web/tests/unit/admin_console_api.test.js @@ -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, + }) + ); + }); +}); diff --git a/web/tests/unit/openclaw_banner_manager.test.js b/web/tests/unit/openclaw_banner_manager.test.js new file mode 100644 index 0000000..5971912 --- /dev/null +++ b/web/tests/unit/openclaw_banner_manager.test.js @@ -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 = ` +
+
+
+ `; + }); + + 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"); + }); +}); diff --git a/web/tests/unit/openclaw_notification_center.test.js b/web/tests/unit/openclaw_notification_center.test.js new file mode 100644 index 0000000..baf5406 --- /dev/null +++ b/web/tests/unit/openclaw_notification_center.test.js @@ -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: "", + severity: "warning", + message: '', + 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("'); + }); +});