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>
918 lines
38 KiB
JavaScript
918 lines
38 KiB
JavaScript
// 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) =>
|
||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||
}
|
||
})();
|