|
|
|
|
@ -35,6 +35,7 @@
|
|
|
|
|
data: {
|
|
|
|
|
cycles: null,
|
|
|
|
|
cycleDetail: {},
|
|
|
|
|
metricsByCycle: {}, // {<cycle_id>: [Metric, ...]} — populated via fan-out
|
|
|
|
|
segments: null,
|
|
|
|
|
recipes: null,
|
|
|
|
|
connections: null,
|
|
|
|
|
@ -349,22 +350,44 @@
|
|
|
|
|
function _deriveKpis(cycles, recipes) {
|
|
|
|
|
const c = cycles || [];
|
|
|
|
|
const r = recipes || [];
|
|
|
|
|
const spend = c.reduce((s, x) => s + _cycleSpend(x), 0);
|
|
|
|
|
// 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 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;
|
|
|
|
|
const ctr = impressions ? (clicks / impressions * 100) : 0;
|
|
|
|
|
return {
|
|
|
|
|
impressions: impressions ? _abbrev(impressions) : "0",
|
|
|
|
|
imprDelta: impressions ? "+12%" : "",
|
|
|
|
|
imprDelta: "", // real delta needs a previous-window snapshot
|
|
|
|
|
ctr: ctr ? (ctr.toFixed(2) + "%") : "0%",
|
|
|
|
|
ctrDelta: ctr ? "-0.3%" : "",
|
|
|
|
|
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() {
|
|
|
|
|
@ -765,6 +788,17 @@
|
|
|
|
|
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") {
|
|
|
|
|
|