svrnty-hermes-webui-plugin/static/adwright.js
Svrnty 01825190a3 feat(adwright panel): wire GetCycleMetrics into Overview KPIs
Frontend now drives Impressions / CTR / Spend from real per-variant
metrics (cycle_metric) rather than hardcoded zeros + fake +12% / -0.3%
deltas. Adds state.data.metricsByCycle keyed by cycle id; populated
via _fetchCycleMetrics which hits the panel-update endpoint directly
(no CMO chat dispatch needed — the route calls gRPC live per request).

After every cycles load (refresh-cycles or list-cycles ingest), fans
out one metrics fetch per cycle. _deriveKpis flattens all metrics
arrays, sums impressions/clicks/spend, computes real CTR from
clicks/impressions. Spend bar now reflects $spend / $budget_total
from real values instead of falling back to the $6,000 default floor.

Plugin route _real_payload_for gains a new branch:
adwright_get_cycle_metrics?cycle_id=N → core.get_cycle_metrics_tool(N).

Fake deltas dropped — real deltas need a previous-window snapshot
the backend doesn't supply yet.

Verified live: metricsByCycle.1 populated with backend's [{variant_id:1,
impressions:0, ...}] record. All metric values still zero in this
sandbox because no ads have dispatched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:57:10 -04:00

918 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Adwright tool panel — injected into hermes-webui as a sibling to #mainChat.
// Loaded via /plugins/svrnty/adwright.js (registered by plugin.py).
// Per ADWRIGHT-PANEL-PRD §3-§7: 60/40 panel/chat split, 5 tabs, always
// visible, soft-disabled when active profile != cmo, reads via /adwright
// chat commands (visible in stream → audit trail), writes for creds only
// via direct backend route (governance exception §6).
//
// Strict: vanilla JS, IIFE, all globals under window.SvrntyAdwright,
// MutationObserver scoped to <main class="main"> only, no hardcoded colors
// (all in adwright.css).
(function () {
"use strict";
if (window.__svrntyAdwrightLoaded) return;
window.__svrntyAdwrightLoaded = true;
const NS = (window.SvrntyAdwright = window.SvrntyAdwright || {});
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "cycles", label: "Cycles" },
{ id: "audience", label: "Audience" },
{ id: "targeting", label: "Targeting" },
{ id: "connections", label: "Connections" },
];
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_MS = 30000;
// ── State ──────────────────────────────────────────────────────────────
NS.state = {
activeTab: "overview",
mounted: false,
pollTimer: null,
pollStartedAt: 0,
lastSeenTs: 0,
pendingTool: null,
data: {
cycles: null,
cycleDetail: {},
metricsByCycle: {}, // {<cycle_id>: [Metric, ...]} — populated via fan-out
segments: null,
recipes: null,
connections: null,
},
};
// ── Active-profile detection (PRD §3 visibility gating) ────────────────
// hermes-webui exposes S.activeProfile (see static/ui.js line 1). We probe
// it defensively — the panel always renders, but enables only for CMO.
// Cache from a real /api/profile/active fetch since window.S may not be
// populated yet on /session/* pages before boot.js runs.
NS.state._fetchedProfile = null;
function _activeProfile() {
if (NS.state._fetchedProfile) return NS.state._fetchedProfile;
try {
return (window.S && window.S.activeProfile) || "default";
} catch (_) { return "default"; }
}
function _isCmoActive() {
return String(_activeProfile()).toLowerCase().indexOf("cmo") === 0;
}
function _activeSessionId() {
try {
return (window.S && window.S.session && window.S.session.session_id) || "";
} catch (_) { return ""; }
}
// ── Mount / unmount ────────────────────────────────────────────────────
// Anchor: <main class="main"> contains #mainChat (the WebUI chat view).
// We inject our panel as a SIBLING before #mainChat. CSS handles the 60/40
// split via :has() (modern browsers — Chrome 105+, Safari 15.4+, Firefox 121+).
function _findMountTargets() {
const main = document.querySelector("main.main");
const chat = document.getElementById("mainChat");
if (!main || !chat) return null;
return { main, chat };
}
function _mount() {
if (NS.state.mounted) return true;
if (document.querySelector(".svrnty-aw-panel")) {
NS.state.mounted = true;
return true;
}
const targets = _findMountTargets();
if (!targets) return false;
const panel = document.createElement("div");
panel.className = "svrnty-aw-panel";
panel.id = "svrntyAdwrightPanel";
panel.innerHTML = _renderShell();
targets.main.insertBefore(panel, targets.chat);
_wireNav(panel);
_wireOverview(panel);
_wireCycles(panel);
_wireConnections(panel);
_refreshDisabledState();
_renderTab(NS.state.activeTab);
// Authoritative profile fetch (window.S may be empty on /session/* paths
// before boot.js finishes). Update banner state once it arrives.
_fetchActiveProfile().then((name) => {
if (name) {
NS.state._fetchedProfile = name;
_refreshDisabledState();
}
});
// Re-poll every 5s so a profile switch from the upstream Profiles panel
// also lifts the banner without requiring a hard reload.
setInterval(() => {
_fetchActiveProfile().then((name) => {
if (name && name !== NS.state._fetchedProfile) {
NS.state._fetchedProfile = name;
_refreshDisabledState();
}
});
}, 5000);
NS.state.mounted = true;
return true;
}
// ── Render: shell + tabs ───────────────────────────────────────────────
function _renderShell() {
const nav = TABS.map((t) =>
`<button class="svrnty-aw-nav-item${t.id === NS.state.activeTab ? " svrnty-aw-nav-active" : ""}" data-svrnty-aw-tab="${t.id}">${t.label}</button>`
).join("");
const tabs = TABS.map((t) =>
`<div class="svrnty-aw-tab" data-svrnty-aw-pane="${t.id}"></div>`
).join("");
return (
'<div class="svrnty-aw-header">' +
'<div class="svrnty-aw-title">Adwright</div>' +
'<div class="svrnty-aw-status">' +
'<span class="svrnty-aw-status-dot" id="svrntyAwDot"></span>' +
'<span id="svrntyAwStatusText">checking…</span>' +
'<button class="svrnty-aw-bte-link" id="svrntyAwOpenBte" title="Open BTE Command Center (creative studio)">↗ BTE</button>' +
'</div>' +
'</div>' +
'<div class="svrnty-aw-body">' +
'<nav class="svrnty-aw-nav">' + nav + '</nav>' +
'<div class="svrnty-aw-content">' +
'<div class="svrnty-aw-disabled-banner" id="svrntyAwDisabledBanner" style="display:none">' +
'Adwright needs the <strong>cmo-planb</strong> profile active. ' +
'<button class="svrnty-aw-btn svrnty-aw-btn-primary" id="svrntyAwSwitchCmo">Switch to CMO</button>' +
'</div>' +
tabs +
'</div>' +
'</div>'
);
}
function _wireNav(panel) {
panel.querySelectorAll("[data-svrnty-aw-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-svrnty-aw-tab");
_activateTab(id);
});
});
// Cross-panel link to BTE Command Center. Route through canonical
// switchPanel so main.svrnty-showing-bte class gets flipped (CSS gates
// overlay visibility on that class). SvrntyBTE.open is fallback for
// the early-boot edge case where the nav wrapper isn't installed yet.
const bteBtn = panel.querySelector("#svrntyAwOpenBte");
if (bteBtn) bteBtn.addEventListener("click", () => {
if (typeof window.switchPanel === "function") {
window.switchPanel("bte", { fromRailClick: true });
} else if (window.SvrntyBTE && window.SvrntyBTE.open) {
window.SvrntyBTE.open();
}
});
// One-click profile switch to cmo-planb.
const switchBtn = panel.querySelector("#svrntyAwSwitchCmo");
if (switchBtn) switchBtn.addEventListener("click", async () => {
switchBtn.disabled = true;
switchBtn.textContent = "Switching…";
try {
const res = await fetch("/api/profile/switch", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "cmo-planb" }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
// Reload to pick up new profile state across the whole webui.
location.reload();
} catch (e) {
switchBtn.disabled = false;
switchBtn.textContent = "Switch failed — retry";
console.error("[svrnty-aw] profile switch failed", e);
}
});
}
// Pull live active profile from server (don't rely on window.S which may
// not be populated yet on /session/* pages before boot.js runs).
async function _fetchActiveProfile() {
try {
const r = await fetch("/api/profile/active", { credentials: "same-origin" });
if (!r.ok) return null;
const d = await r.json();
return (d && (d.name || d.profile)) || null;
} catch (_) { return null; }
}
function _activateTab(id) {
NS.state.activeTab = id;
const panel = document.getElementById("svrntyAdwrightPanel");
if (!panel) return;
panel.querySelectorAll(".svrnty-aw-nav-item").forEach((btn) => {
btn.classList.toggle("svrnty-aw-nav-active",
btn.getAttribute("data-svrnty-aw-tab") === id);
});
panel.querySelectorAll(".svrnty-aw-tab").forEach((pane) => {
pane.classList.toggle("svrnty-aw-tab-active",
pane.getAttribute("data-svrnty-aw-pane") === id);
});
_renderTab(id);
}
function _refreshDisabledState() {
const dot = document.getElementById("svrntyAwDot");
const txt = document.getElementById("svrntyAwStatusText");
const banner = document.getElementById("svrntyAwDisabledBanner");
if (!dot || !txt) return;
const cmo = _isCmoActive();
dot.classList.toggle("svrnty-aw-active", cmo);
dot.classList.toggle("svrnty-aw-inactive", !cmo);
txt.textContent = cmo
? "cmo · ready"
: (_activeProfile() + " · view-only");
if (banner) banner.style.display = cmo ? "none" : "block";
// Disable all action buttons when CMO not active.
const panel = document.getElementById("svrntyAdwrightPanel");
if (panel) {
panel.querySelectorAll("[data-svrnty-aw-needs-cmo]").forEach((b) => {
b.disabled = !cmo;
});
}
}
// ── Tab renderers ─────────────────────────────────────────────────────
function _renderTab(id) {
if (id === "overview") return _renderOverview();
if (id === "cycles") return _renderCycles();
if (id === "audience") return _renderAudience();
if (id === "targeting") return _renderTargeting();
if (id === "connections") return _renderConnections();
}
function _pane(id) {
const panel = document.getElementById("svrntyAdwrightPanel");
if (!panel) return null;
return panel.querySelector(`[data-svrnty-aw-pane="${id}"]`);
}
// Overview — KPIs + spend bar + last-flow timeline ─────────────────────
function _renderOverview() {
const pane = _pane("overview");
if (!pane) return;
const d = NS.state.data;
const kpis = _deriveKpis(d.cycles, d.recipes);
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Overview</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="refresh-overview" data-svrnty-aw-needs-cmo>Refresh</button>' +
'</div>' +
'<div class="svrnty-aw-kpi-grid">' +
_kpiCard("Impressions", kpis.impressions, kpis.imprDelta) +
_kpiCard("CTR", kpis.ctr, kpis.ctrDelta) +
_kpiCard("Cycles", kpis.cycleCount, "") +
_kpiCard("Recipes", kpis.recipeCount, "") +
'</div>' +
'<div class="svrnty-aw-section">' +
'<div class="svrnty-aw-section-title">Spend</div>' +
'<div style="font-size:12px;color:var(--muted)">$' +
_fmt(kpis.spend) + ' / $' + _fmt(kpis.budget) + '</div>' +
'<div class="svrnty-aw-spend-bar"><div class="svrnty-aw-spend-bar-fill" style="width:' +
Math.min(100, Math.round((kpis.spend / Math.max(1, kpis.budget)) * 100)) + '%"></div></div>' +
'</div>' +
'<div class="svrnty-aw-section">' +
'<div class="svrnty-aw-section-title">Last flow timeline</div>' +
_renderTimeline(d.cycles) +
'</div>';
_rewireActions(pane);
_refreshDisabledState();
}
function _wireOverview(panel) {
// Initial paint on first mount triggers a refresh attempt.
// Defer so the panel renders first; chat send needs S.session ready.
setTimeout(() => {
if (NS.state.activeTab === "overview" && _isCmoActive()) {
_fireAction("refresh-overview");
}
}, 500);
}
function _kpiCard(label, value, delta) {
const dCls = delta && delta.startsWith("-")
? "svrnty-aw-kpi-delta-down"
: (delta ? "svrnty-aw-kpi-delta-up" : "");
return (
'<div class="svrnty-aw-kpi">' +
'<div class="svrnty-aw-kpi-label">' + _esc(label) + '</div>' +
'<div class="svrnty-aw-kpi-value">' + _esc(value) + '</div>' +
(delta ? '<div class="svrnty-aw-kpi-delta ' + dCls + '">' + _esc(delta) + '</div>' : '') +
'</div>'
);
}
function _renderTimeline(cycles) {
if (!cycles || !cycles.length) {
return '<div class="svrnty-aw-empty">No recent activity. Click Refresh.</div>';
}
return '<div class="svrnty-aw-timeline">' +
cycles.slice(0, 5).map((c) =>
'<div class="svrnty-aw-timeline-item">' +
'<div class="svrnty-aw-timeline-time">' + _esc(c.started_at || "") + '</div>' +
'<div class="svrnty-aw-timeline-text">' +
_esc(c.title || ("Cycle #" + (c.id || "?"))) +
' — <span style="color:var(--muted)">' + _esc(_humanStatus(c.status)) + '</span>' +
'</div>' +
'</div>'
).join("") +
'</div>';
}
// Backend proto field for cycle budget is `budget_total` (decimal string,
// e.g. "200.00"). Spend lives in cycle_metric (fetched via GetCycleMetrics
// — not in ListCycles), so it stays 0 until that wire lands.
function _cycleBudget(x) {
return parseFloat(x.budget_total || x.budget || 0) || 0;
}
function _cycleSpend(x) {
return parseFloat(x.spend || 0) || 0;
}
// Backend serializes enum as canonical proto string ("CYCLE_STATUS_PREVIEW_READY",
// "VARIANT_STATUS_ACTIVE", etc.). Drop the prefix + title-case the rest.
const _STATUS_LABELS = {
"CYCLE_STATUS_ANALYZING": "Analyzing",
"CYCLE_STATUS_PREVIEW_READY": "Preview ready",
"CYCLE_STATUS_RUNNING": "Running",
"CYCLE_STATUS_COMPLETE": "Complete",
"CYCLE_STATUS_FAILED": "Failed",
"VARIANT_STATUS_PREVIEW_READY": "Preview ready",
"VARIANT_STATUS_ACTIVE": "Active",
"VARIANT_STATUS_PAUSED": "Paused",
"VARIANT_STATUS_COMPLETE": "Complete",
};
function _humanStatus(s) {
if (!s) return "";
return _STATUS_LABELS[s] || s;
}
function _deriveKpis(cycles, recipes) {
const c = cycles || [];
const r = recipes || [];
// Metrics live in cycle_metric (GetCycleMetrics), fanned out per cycle
// into state.data.metricsByCycle. Flatten across all loaded cycles for
// the Overview totals. Stays 0 until ads dispatch + Meta polling fills.
const mby = NS.state.data.metricsByCycle || {};
const allMetrics = c.flatMap((x) => mby[x.id] || []);
const impressions = allMetrics.reduce((s, m) => s + (m.impressions || 0), 0);
const clicks = allMetrics.reduce((s, m) => s + (m.clicks || 0), 0);
const spend = allMetrics.reduce((s, m) => s + (parseFloat(m.spend) || 0), 0);
const budget = c.reduce((s, x) => s + _cycleBudget(x), 0) || 6000;
const ctr = impressions ? (clicks / impressions * 100) : 0;
return {
impressions: impressions ? _abbrev(impressions) : "0",
imprDelta: "", // real delta needs a previous-window snapshot
ctr: ctr ? (ctr.toFixed(2) + "%") : "0%",
ctrDelta: "", // same — drop fake delta until backend supplies
cycleCount: String(c.length),
recipeCount: String(r.length),
spend: spend,
budget: budget,
};
}
// Fan-out: fetch metrics for one cycle straight from the panel-update
// endpoint (no CMO chat needed — route calls gRPC live per request).
function _fetchCycleMetrics(cycleId) {
if (!cycleId && cycleId !== 0) return;
const url = "/api/adwright/last-panel-update?session_id=" +
encodeURIComponent(_activeSessionId()) +
"&since=0&tool=adwright_get_cycle_metrics&cycle_id=" +
encodeURIComponent(cycleId);
fetch(url)
.then((r) => r.json())
.then((r) => {
if (!r || !r.update || !r.update.payload) return;
NS.state.data.metricsByCycle[cycleId] = r.update.payload.metrics || [];
if (NS.state.activeTab === "overview") _renderTab("overview");
})
.catch(() => { /* best-effort */ });
}
// Cycles — list with click-to-expand ───────────────────────────────────
function _renderCycles() {
const pane = _pane("cycles");
if (!pane) return;
const cycles = NS.state.data.cycles; // null = never fetched, [] = fetched empty
const emptyMsg = cycles === null
? 'No cycles loaded. Click Refresh cycles.'
: '0 cycles in Adwright. Create a cycle via CMO chat to dispatch ads.';
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Cycles</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="refresh-cycles" data-svrnty-aw-needs-cmo>Refresh cycles</button>' +
'</div>' +
((cycles && cycles.length)
? ('<div class="svrnty-aw-list">' +
cycles.map((c) => _cycleRow(c)).join("") +
'</div>')
: '<div class="svrnty-aw-empty">' + emptyMsg + '</div>');
_rewireActions(pane);
pane.querySelectorAll("[data-svrnty-aw-cycle]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.getAttribute("data-svrnty-aw-cycle");
_toggleCycleDetail(row, id);
});
});
_refreshDisabledState();
}
function _cycleRow(c) {
return (
'<div class="svrnty-aw-row" data-svrnty-aw-cycle="' + _attr(c.id) + '">' +
'<div class="svrnty-aw-row-main">' +
'<div class="svrnty-aw-row-title">' + _esc(c.title || ("Cycle #" + c.id)) + '</div>' +
'<div class="svrnty-aw-row-sub">' + _esc(_humanStatus(c.status)) + ' · started ' + _esc(c.started_at || "") + '</div>' +
'</div>' +
'<div class="svrnty-aw-row-meta">$' + _fmt(_cycleSpend(c)) + ' / $' + _fmt(_cycleBudget(c)) + '</div>' +
'</div>'
);
}
function _toggleCycleDetail(rowEl, cycleId) {
const existing = rowEl.nextElementSibling;
if (existing && existing.classList.contains("svrnty-aw-row-expanded")) {
existing.remove();
return;
}
const detail = NS.state.data.cycleDetail[cycleId];
const wrap = document.createElement("div");
wrap.className = "svrnty-aw-row-expanded";
if (detail) {
wrap.innerHTML = _renderCycleDetail(detail);
} else {
wrap.innerHTML = '<div class="svrnty-aw-loading">Loading cycle #' + _esc(cycleId) + '…</div>';
_fireAction("get-cycle", { id: cycleId });
}
rowEl.parentNode.insertBefore(wrap, rowEl.nextSibling);
}
function _renderCycleDetail(d) {
const variants = d.variants || [];
if (!variants.length) return '<div class="svrnty-aw-empty">No variants for this cycle.</div>';
return '<strong style="font-size:12px">Variants (' + variants.length + ')</strong>' +
'<div style="margin-top:6px;display:flex;flex-direction:column;gap:4px">' +
variants.map((v) =>
'<div style="display:flex;justify-content:space-between;font-size:12px">' +
'<span>' + _esc(v.name || v.id || "variant") + '</span>' +
'<span style="color:var(--muted)">' + _esc(_humanStatus(v.status)) + ' · imp ' + _fmt(v.impressions || 0) + '</span>' +
'</div>'
).join("") +
'</div>';
}
function _wireCycles(panel) { /* nav handler fires render */ }
// Audience — segments list ────────────────────────────────────────────
function _renderAudience() {
const pane = _pane("audience");
if (!pane) return;
const segs = NS.state.data.segments; // null = never fetched, [] = fetched empty
const segList = segs || [];
const emptyMsg = segs === null
? 'No segments loaded. Click Refresh.'
: '0 segments in Adwright. Define segments via CMO chat (e.g., "add segment vegan-31-50").';
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Audience segments</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="list-segments" data-svrnty-aw-needs-cmo>Refresh</button>' +
'</div>' +
(segList.length
? '<div class="svrnty-aw-list">' +
segList.map((s) =>
'<div class="svrnty-aw-row">' +
'<div class="svrnty-aw-row-main">' +
'<div class="svrnty-aw-row-title">' + _esc(s.name || s.id) + '</div>' +
'<div class="svrnty-aw-row-sub">' + _esc(s.description || "") + '</div>' +
'</div>' +
'<div class="svrnty-aw-row-meta">' + _fmt(s.size || 0) + ' people</div>' +
'</div>'
).join("") +
'</div>'
: '<div class="svrnty-aw-empty">' + emptyMsg + '</div>');
_rewireActions(pane);
_refreshDisabledState();
}
// Targeting — segment × recipe matrix ─────────────────────────────────
function _renderTargeting() {
const pane = _pane("targeting");
if (!pane) return;
const segs = NS.state.data.segments || [];
const recs = NS.state.data.recipes || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Targeting matrix</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="list-recipes" data-svrnty-aw-needs-cmo>Refresh recipes</button>' +
'</div>' +
(segs.length && recs.length
? _renderMatrix(segs, recs)
: '<div class="svrnty-aw-empty">Load segments + recipes to see the matrix.<br>' +
'<span style="font-size:11px">(Audience tab → Refresh, then Refresh recipes here.)</span></div>');
_rewireActions(pane);
_refreshDisabledState();
}
function _renderMatrix(segs, recs) {
const cols = recs.length + 1;
const cells = [];
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-head"></div>');
recs.forEach((r) => {
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-head">' +
_esc(r.name || r.id) + '</div>');
});
segs.forEach((s) => {
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-row-label">' +
_esc(s.name || s.id) + '</div>');
recs.forEach((r) => {
cells.push('<div class="svrnty-aw-matrix-cell">·</div>');
});
});
return '<div class="svrnty-aw-matrix" style="grid-template-columns:repeat(' + cols + ',minmax(80px,1fr))">' +
cells.join("") + '</div>';
}
// Connections — read status + provisioning form (governance exception) ─
function _renderConnections() {
const pane = _pane("connections");
if (!pane) return;
const conns = NS.state.data.connections; // null = never fetched, [] = fetched empty
const connList = conns || [];
const emptyConnMsg = conns === null
? 'No status loaded. Click Re-check.'
: '0 connections configured. Provision Meta + Woo credentials below.';
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Connections</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="connections-status" data-svrnty-aw-needs-cmo>Re-check</button>' +
'</div>' +
'<div class="svrnty-aw-conn-list">' +
(connList.length ? connList.map(_connRow).join("") :
'<div class="svrnty-aw-empty">' + emptyConnMsg + '</div>') +
'</div>' +
'<button class="svrnty-aw-btn svrnty-aw-btn-primary" data-svrnty-aw-action="open-cred-form">Fix credentials</button>' +
'<form class="svrnty-aw-form" id="svrntyAwCredForm">' +
'<div class="svrnty-aw-form-note">' +
'Credentials are written direct to the credctl vault via the plugin backend — ' +
'<strong>never through chat or MCP</strong> (governance exception, see PRD §6). ' +
'Leave a field blank to keep its current value.' +
'</div>' +
_credField("meta-app-id", "Meta — App ID", "text") +
_credField("meta-app-secret", "Meta — App Secret", "password") +
_credField("meta-sandbox-access-token", "Meta — Sandbox Access Token", "password") +
_credField("meta-sandbox-ad-account", "Meta — Sandbox Ad Account ID", "text") +
_credField("meta-sandbox-page-id", "Meta — Sandbox Page ID", "text") +
_credField("woocommerce-ck", "WooCommerce — Consumer Key", "password") +
_credField("woocommerce-cs", "WooCommerce — Consumer Secret", "password") +
'<div class="svrnty-aw-form-actions">' +
'<button type="button" class="svrnty-aw-btn" data-svrnty-aw-action="close-cred-form">Cancel</button>' +
'<button type="submit" class="svrnty-aw-btn svrnty-aw-btn-primary">Save credentials</button>' +
'</div>' +
'</form>';
_rewireActions(pane);
_refreshDisabledState();
}
function _credField(name, label, type) {
return '<div class="svrnty-aw-form-row">' +
'<label for="svrntyAwCred-' + name + '">' + _esc(label) + '</label>' +
'<input id="svrntyAwCred-' + name + '" name="' + name + '" type="' + type + '" autocomplete="off">' +
'</div>';
}
function _connRow(c) {
const okCls = c.ok ? " svrnty-aw-ok" : " svrnty-aw-fail";
return (
'<div class="svrnty-aw-conn">' +
'<div class="svrnty-aw-conn-name">' +
_esc(c.name || c.id || "unknown") +
'</div>' +
'<span class="svrnty-aw-conn-pill' + okCls + '">' +
_esc(c.status || (c.ok ? "ok" : "fail")) +
'</span>' +
'</div>'
);
}
function _wireConnections(panel) {
panel.addEventListener("submit", (e) => {
if (e.target && e.target.id === "svrntyAwCredForm") {
e.preventDefault();
_submitCredForm(e.target);
}
});
}
function _submitCredForm(form) {
const fd = new FormData(form);
const payload = {};
fd.forEach((v, k) => { if (v && String(v).trim()) payload[k] = String(v); });
if (!Object.keys(payload).length) {
_toast("No fields filled — nothing to save.", "error");
return;
}
_toast("Provisioning credentials…");
fetch("/api/adwright/provision-creds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then((r) => r.json())
.then((r) => {
if (r && r.ok) {
_toast("Credentials saved (" + (r.written || []).length + " field" +
((r.written || []).length === 1 ? "" : "s") + ").", "success");
form.reset();
form.classList.remove("svrnty-aw-open");
_fireAction("connections-status");
} else {
_toast("Provisioning failed: " + ((r && r.error) || "unknown"), "error");
}
})
.catch((e) => _toast("Provisioning request failed: " + (e && e.message), "error"));
}
// ── Action wiring ──────────────────────────────────────────────────────
// Reads: panel button → custom event → injects "/adwright <verb>" into the
// existing WebUI composer (#msg) and clicks #btnSend so the user message
// shows in chat. Then polls /api/adwright/last-panel-update.
// Writes (creds only): direct POST to /api/adwright/provision-creds.
function _rewireActions(scope) {
scope.querySelectorAll("[data-svrnty-aw-action]").forEach((btn) => {
if (btn.dataset.svrntyAwWired === "1") return;
btn.dataset.svrntyAwWired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const action = btn.getAttribute("data-svrnty-aw-action");
_handleAction(action, btn);
});
});
}
function _handleAction(action, btn) {
if (action === "open-cred-form") {
const f = document.getElementById("svrntyAwCredForm");
if (f) f.classList.add("svrnty-aw-open");
return;
}
if (action === "close-cred-form") {
const f = document.getElementById("svrntyAwCredForm");
if (f) f.classList.remove("svrnty-aw-open");
return;
}
_fireAction(action);
}
// User-facing label for each action — shown via our toast so the user sees
// "Refreshing Overview" rather than the raw `/adwright refresh-cycles` slash
// command that hermes-webui's native Queued banner displays underneath.
const _ACTION_LABELS = {
"refresh-overview": "Refreshing Overview…",
"refresh-cycles": "Refreshing cycles…",
"get-cycle": "Loading cycle…",
"list-segments": "Loading segments…",
"list-recipes": "Loading recipes…",
"connections-status": "Checking connections…",
};
// Compound actions — one user gesture, multiple underlying tool calls.
// Overview KPIs need both cycles (for Cycles count + Spend + timeline)
// AND recipes (for Recipes count). Fire both so KPIs stay coherent.
const _COMPOUND_ACTIONS = {
"refresh-overview": ["refresh-cycles", "list-recipes"],
};
function _fireAction(action, args) {
if (!_isCmoActive()) {
_toast("Switch to CMO profile to run /adwright commands.", "error");
return;
}
args = args || {};
if (_COMPOUND_ACTIONS[action]) {
if (_ACTION_LABELS[action]) _toast(_ACTION_LABELS[action]);
_COMPOUND_ACTIONS[action].forEach((sub) => _fireAction(sub, args));
return;
}
const cmd = _commandFor(action, args);
if (!cmd) return;
// Dispatch event for any external listeners (audit / tests).
try {
document.dispatchEvent(new CustomEvent("adwright:action",
{ detail: { action: action, args: args, command: cmd } }));
} catch (_) { /* ignore */ }
NS.state.pendingTool = _toolFor(action);
if (!_sendChatCommand(cmd)) {
_toast("Couldn't post to chat — is a session open?", "error");
return;
}
if (_ACTION_LABELS[action]) _toast(_ACTION_LABELS[action]);
_startPolling();
}
function _commandFor(action, args) {
switch (action) {
case "refresh-overview": return "/adwright refresh-cycles";
case "refresh-cycles": return "/adwright refresh-cycles";
case "get-cycle": return "/adwright get-cycle " + (args.id || "");
case "list-segments": return "/adwright list-segments";
case "list-recipes": return "/adwright list-recipes";
case "connections-status": return "/adwright connections-status";
default: return null;
}
}
function _toolFor(action) {
switch (action) {
case "refresh-overview":
case "refresh-cycles": return "adwright_refresh_cycles";
case "get-cycle": return "adwright_get_cycle";
case "list-segments": return "adwright_list_segments";
case "list-recipes": return "adwright_list_recipes";
case "connections-status": return "adwright_get_connections_status";
default: return null;
}
}
function _sendChatCommand(text) {
const msg = document.getElementById("msg");
const btn = document.getElementById("btnSend");
if (!msg || !btn) return false;
msg.value = text;
// Notify WebUI listeners that the textarea changed (enables btnSend).
try { msg.dispatchEvent(new Event("input", { bubbles: true })); } catch (_) {}
// Wait one tick so btnSend's disabled state updates, then click.
setTimeout(() => {
try { btn.click(); } catch (_) { /* ignore */ }
}, 30);
return true;
}
// ── Polling: /api/adwright/last-panel-update ──────────────────────────
function _startPolling() {
// Reset deadline on every call so back-to-back sub-actions (compound
// refresh-overview = refresh-cycles + list-recipes) each get a fresh
// POLL_MAX_MS window — CMO processes chat messages sequentially and
// each tool can take ~25s.
NS.state.pollStartedAt = Date.now();
if (NS.state.pollTimer) return;
NS.state.pollTimer = setInterval(_pollOnce, POLL_INTERVAL_MS);
// Run once immediately so we don't wait the first 2s.
_pollOnce();
}
function _stopPolling() {
if (NS.state.pollTimer) {
clearInterval(NS.state.pollTimer);
NS.state.pollTimer = null;
}
}
function _pollOnce() {
if (Date.now() - NS.state.pollStartedAt > POLL_MAX_MS) {
_stopPolling();
return;
}
const sid = _activeSessionId();
const tool = NS.state.pendingTool || "";
const url = "/api/adwright/last-panel-update?session_id=" +
encodeURIComponent(sid) + "&since=" + NS.state.lastSeenTs +
"&tool=" + encodeURIComponent(tool);
fetch(url)
.then((r) => r.json())
.then((r) => {
if (!r || !r.update) return;
const u = r.update;
if (u.ts && u.ts <= NS.state.lastSeenTs) return;
NS.state.lastSeenTs = u.ts || Date.now();
// Sign of life — reset deadline. Lets compound actions (two CMO
// calls back-to-back, ~50s total) finish even when each sub-action
// shares one POLL_MAX_MS window with its sibling.
NS.state.pollStartedAt = Date.now();
_ingestUpdate(u);
// Got our update — stop polling only when the response matches the
// CURRENT pendingTool. Compound actions overwrite pendingTool after
// sub1's fetch is already in flight; if sub1's response arrives we
// ingest it but keep polling for sub2.
if (NS.state.pendingTool && u.tool === NS.state.pendingTool) {
NS.state.pendingTool = null;
_stopPolling();
}
_renderTab(NS.state.activeTab);
})
.catch(() => { /* polling is best-effort */ });
}
function _ingestUpdate(u) {
const tool = u.tool || "";
const payload = u.payload || {};
if (tool === "adwright_refresh_cycles" || tool === "adwright_list_cycles") {
NS.state.data.cycles = payload.cycles || [];
// Fan out metrics fetch for each cycle so Overview KPIs (impressions /
// CTR / spend) come from cycle_metric, not from cycle headers (which
// don't carry them).
(payload.cycles || []).forEach((c) => {
if (c && (c.id != null)) _fetchCycleMetrics(c.id);
});
} else if (tool === "adwright_get_cycle_metrics") {
// Direct fetch (no chat) — payload has {metrics:[...]} but no cycle_id;
// the fetch URL carried cycle_id, so this branch is mostly for the rare
// case the polling channel sees a metrics update first. Skip — handled
// by _fetchCycleMetrics promise resolution.
} else if (tool === "adwright_get_cycle") {
if (payload.id != null) NS.state.data.cycleDetail[payload.id] = payload;
} else if (tool === "adwright_list_segments") {
NS.state.data.segments = payload.segments || [];
} else if (tool === "adwright_list_recipes") {
NS.state.data.recipes = payload.recipes || [];
} else if (tool === "adwright_get_connections_status") {
// Real adwright-mcp returns {meta:{connected,account_id,...}, woo:{connected,store_url,...}}
// Legacy mock returned {connections:[...]}. Support both.
if (Array.isArray(payload.connections)) {
NS.state.data.connections = payload.connections;
} else {
const out = [];
if (payload.meta) {
const m = payload.meta;
out.push({
id: "meta",
name: m.account_name ? "Meta — " + m.account_name : "Meta (Facebook + Instagram)",
status: m.connected ? "ok" : "down",
ok: !!m.connected,
detail: m.connected ? `${m.account_id} · ${m.currency} · spent ${m.amount_spent || 0}` : (m.error || "not connected"),
});
}
if (payload.woo) {
const w = payload.woo;
out.push({
id: "woo",
name: "WooCommerce" + (w.store_url ? " — " + w.store_url.replace(/^https?:\/\//, "") : ""),
status: w.connected ? "ok" : "down",
ok: !!w.connected,
detail: w.connected ? `WC ${w.wc_version || "?"} · WP ${w.wp_version || "?"} · ${w.currency || ""}` : (w.error || "not connected"),
});
}
NS.state.data.connections = out;
}
}
}
// ── Toast ─────────────────────────────────────────────────────────────
function _toast(text, kind) {
let el = document.getElementById("svrntyAwToast");
if (!el) {
el = document.createElement("div");
el.id = "svrntyAwToast";
el.className = "svrnty-aw-toast";
document.body.appendChild(el);
}
el.className = "svrnty-aw-toast" + (kind ? " svrnty-aw-toast-" + kind : "");
el.textContent = text;
requestAnimationFrame(() => el.classList.add("svrnty-aw-toast-show"));
clearTimeout(_toast._t);
_toast._t = setTimeout(() => {
el.classList.remove("svrnty-aw-toast-show");
}, 3000);
}
// ── Helpers ───────────────────────────────────────────────────────────
function _esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function _attr(s) { return _esc(s); }
function _fmt(n) {
n = Number(n) || 0;
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
}
function _abbrev(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return String(n);
}
// ── Bootstrap ─────────────────────────────────────────────────────────
// Mount when <main class="main"> exists; observer scoped to <body> children
// (NOT subtree) so we don't fight other observers. Reattach the profile
// watcher whenever the panel is (re)mounted.
function _bootstrap() {
if (_mount()) {
_startProfileWatcher();
return;
}
const obs = new MutationObserver(() => {
if (_mount()) {
_startProfileWatcher();
obs.disconnect();
}
});
obs.observe(document.body, { childList: true, subtree: false });
// Fallback re-check (some shells rebuild <main> after route changes).
setTimeout(() => { _mount(); _startProfileWatcher(); }, 1500);
}
let _profileWatcherStarted = false;
function _startProfileWatcher() {
if (_profileWatcherStarted) return;
_profileWatcherStarted = true;
let last = _activeProfile();
setInterval(() => {
const now = _activeProfile();
if (now !== last) {
last = now;
_refreshDisabledState();
}
}, 1000);
}
NS.mount = _mount;
NS.fireAction = _fireAction;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _bootstrap);
} else {
_bootstrap();
}
})();