Files
ComfyUI-OpenClaw/web/admin_console_app.js

564 lines
23 KiB
JavaScript

import { createRemoteAdminApi, parseSseChunk } from "./admin_console_api.js";
import { stampHostSurfaceMetadata } from "./openclaw_host_surface.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 metadataTarget = root.body || root.documentElement;
if (metadataTarget) {
// IMPORTANT: keep admin console host evidence explicit so desktop-host
// regressions can be separated from standalone frontend drift in E2E.
stampHostSurfaceMetadata(metadataTarget, { win: view });
}
const api = createRemoteAdminApi({
fetchImpl: view.fetch.bind(view),
storage: view.localStorage,
});
const elements = createElements(root);
elements.token.value = api.getToken();
function appendEvent(obj) {
const seq = Number(obj?.seq);
if (!Number.isNaN(seq)) {
if (seq <= api.state.lastSeq) {
return;
}
api.state.lastSeq = seq;
}
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;
}
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() {
let polls = 0;
let totalEvents = 0;
let cursor = api.state.lastSeq;
while (polls < 4) {
const response = await api.request(`/events?since=${encodeURIComponent(String(cursor))}&limit=50`);
if (!response.ok) {
setStatus(elements.eventsStatus, `Events poll failed: ${response.error || "unknown"}`, "err");
return;
}
const events = Array.isArray(response.data?.events) ? response.data.events : [];
events.forEach(appendEvent);
totalEvents += events.length;
const nextSinceSeq = Number(response.data?.delta?.next_since_seq);
cursor = Number.isFinite(nextSinceSeq) ? nextSinceSeq : api.state.lastSeq;
polls += 1;
if (!response.data?.delta?.truncated) break;
}
setStatus(elements.eventsStatus, `Polled ${totalEvents} 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,
};
}