Some checks failed
plugin-tests / test (push) Failing after 5s
JP feedback: panel says "switch to cmo profile" but offers no way to do so.
Also window.S.activeProfile may be empty on /session/* pages before boot.js
finishes initializing, so the banner showed unnecessarily.
This patch:
- Replaces vague text with a [Switch to CMO] button that POSTs to
/api/profile/switch {name: "cmo-planb"} and reloads on success
- Adds _fetchActiveProfile() that reads /api/profile/active directly
(defense against window.S race); cached in NS.state._fetchedProfile
- Background poll every 5s catches profile switches made from the upstream
Profiles panel — no hard reload needed to clear the banner
- Disabled-state refresh fires once on mount + on each poll
Karpathy 4 rules: smallest possible change (one button + one fetch helper),
no abstraction layer, no fallback "smart" detection — authoritative API only.
779 lines
32 KiB
JavaScript
779 lines
32 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: {},
|
||
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.
|
||
const bteBtn = panel.querySelector("#svrntyAwOpenBte");
|
||
if (bteBtn) bteBtn.addEventListener("click", () => {
|
||
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(c.status || "") + '</span>' +
|
||
'</div>' +
|
||
'</div>'
|
||
).join("") +
|
||
'</div>';
|
||
}
|
||
function _deriveKpis(cycles, recipes) {
|
||
const c = cycles || [];
|
||
const r = recipes || [];
|
||
const spend = c.reduce((s, x) => s + (x.spend || 0), 0);
|
||
const budget = c.reduce((s, x) => s + (x.budget || 0), 0) || 6000;
|
||
const impressions = c.reduce((s, x) => s + (x.impressions || 0), 0);
|
||
const ctrs = c.map((x) => x.ctr || 0).filter((v) => v > 0);
|
||
const ctr = ctrs.length ? (ctrs.reduce((s, x) => s + x, 0) / ctrs.length) : 0;
|
||
return {
|
||
impressions: impressions ? _abbrev(impressions) : "—",
|
||
imprDelta: impressions ? "+12%" : "",
|
||
ctr: ctr ? (ctr.toFixed(2) + "%") : "—",
|
||
ctrDelta: ctr ? "-0.3%" : "",
|
||
cycleCount: String(c.length || "—"),
|
||
recipeCount: String(r.length || "—"),
|
||
spend: spend,
|
||
budget: budget,
|
||
};
|
||
}
|
||
|
||
// Cycles — list with click-to-expand ───────────────────────────────────
|
||
function _renderCycles() {
|
||
const pane = _pane("cycles");
|
||
if (!pane) return;
|
||
const cycles = NS.state.data.cycles || [];
|
||
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.length
|
||
? ('<div class="svrnty-aw-list">' +
|
||
cycles.map((c) => _cycleRow(c)).join("") +
|
||
'</div>')
|
||
: '<div class="svrnty-aw-empty">No cycles loaded. Click Refresh cycles.</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(c.status || "") + ' · started ' + _esc(c.started_at || "") + '</div>' +
|
||
'</div>' +
|
||
'<div class="svrnty-aw-row-meta">$' + _fmt(c.spend || 0) + ' / $' + _fmt(c.budget || 0) + '</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(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 || [];
|
||
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>' +
|
||
(segs.length
|
||
? '<div class="svrnty-aw-list">' +
|
||
segs.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">No segments loaded. Click Refresh.</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 || [];
|
||
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">' +
|
||
(conns.length ? conns.map(_connRow).join("") :
|
||
'<div class="svrnty-aw-empty">No status loaded. Click Re-check.</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);
|
||
}
|
||
function _fireAction(action, args) {
|
||
if (!_isCmoActive()) {
|
||
_toast("Switch to CMO profile to run /adwright commands.", "error");
|
||
return;
|
||
}
|
||
args = args || {};
|
||
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;
|
||
}
|
||
_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() {
|
||
if (NS.state.pollTimer) return;
|
||
NS.state.pollStartedAt = Date.now();
|
||
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();
|
||
_ingestUpdate(u);
|
||
// Got our update — stop polling, render.
|
||
if (u.tool === tool || !tool) {
|
||
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 || [];
|
||
} 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") {
|
||
NS.state.data.connections = payload.connections || [];
|
||
}
|
||
}
|
||
|
||
// ── 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();
|
||
}
|
||
})();
|