{
};
interface DashboardProps {
- platform?: string;
+ platform?: Platform;
+ localData?: FxMSMessageInfo[];
+ experimentAndBranchInfo: any[];
+ totalExperiments: number;
+ msgRolloutInfo: any[];
+ totalRolloutExperiments: number;
}
-export const Dashboard = async (
- { platform }: DashboardProps = { platform: "desktop" },
-) => {
- const {
- localData,
- experimentAndBranchInfo,
- totalExperiments,
- msgRolloutInfo,
- totalRolloutExperiments,
- } = await fetchData();
+export const Dashboard = async ({
+ platform = "firefox-desktop",
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+}: DashboardProps) => {
+ const platformDisplayName = platformDictionary[platform].displayName;
return (
@@ -288,10 +82,15 @@ export const Dashboard = async (
-
+ {localData ? (
+
+ ) : null}
- Current {platform} Message Rollouts
+ Current {platformDisplayName} Message Rollouts
- Current {platform} Message Experiments
+ Current {platformDisplayName} Message Experiments
infra including this, we're going to need to do
+ // something better than just pass "false" as the first param here.
+ const recipeCollection = new NimbusRecipeCollection(false, platform);
+ await recipeCollection.fetchRecipes();
+ console.log("recipeCollection.length = ", recipeCollection.recipes.length);
+
+ const localData = (await getASRouterLocalMessageInfoFromFile()).sort(
+ compareSurfacesFn,
+ );
+
+ const msgExpRecipeCollection =
+ await getMsgExpRecipeCollection(recipeCollection);
+ const msgRolloutRecipeCollection =
+ await getMsgRolloutCollection(recipeCollection);
+
+ const experimentAndBranchInfo = isLookerEnabled
+ ? await msgExpRecipeCollection.getExperimentAndBranchInfos()
+ : msgExpRecipeCollection.recipes.map((recipe: NimbusRecipe) =>
+ recipe.getRecipeInfo(),
+ );
+
+ const totalExperiments = msgExpRecipeCollection.recipes.length;
+
+ const msgRolloutInfo = isLookerEnabled
+ ? await msgRolloutRecipeCollection.getExperimentAndBranchInfos()
+ : msgRolloutRecipeCollection.recipes.map((recipe: NimbusRecipe) =>
+ recipe.getRecipeInfo(),
+ );
+
+ const totalRolloutExperiments = msgRolloutRecipeCollection.recipes.length;
+
+ return {
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+ };
+}
+export async function getMsgExpRecipeCollection(
+ recipeCollection: NimbusRecipeCollection,
+): Promise {
+ const expOnlyCollection = new NimbusRecipeCollection();
+ expOnlyCollection.recipes = recipeCollection.recipes.filter((recipe) =>
+ recipe.isExpRecipe(),
+ );
+ console.log("expOnlyCollection.length = ", expOnlyCollection.recipes.length);
+
+ const msgExpRecipeCollection = new NimbusRecipeCollection();
+ msgExpRecipeCollection.recipes = expOnlyCollection.recipes
+ .filter((recipe) => recipe.usesMessagingFeatures())
+ .sort(compareDatesFn);
+ console.log(
+ "msgExpRecipeCollection.length = ",
+ msgExpRecipeCollection.recipes.length,
+ );
+
+ return msgExpRecipeCollection;
+}
+/**
+ * @returns message data in the form of FxMSMessageInfo from
+ * lib/asrouter-local-prod-messages/data.json and also FxMS telemetry data if
+ * Looker credentials are enabled.
+ */
+
+export async function getASRouterLocalMessageInfoFromFile(): Promise<
+ FxMSMessageInfo[]
+> {
+ const fs = require("fs");
+
+ let data = fs.readFileSync(
+ "lib/asrouter-local-prod-messages/data.json",
+ "utf8",
+ );
+ let json_data = JSON.parse(data);
+
+ if (isLookerEnabled) {
+ json_data = await appendFxMSTelemetryData(json_data);
+ }
+
+ let messages = await Promise.all(
+ json_data.map(async (messageDef: any): Promise => {
+ return await getASRouterLocalColumnFromJSON(messageDef);
+ }),
+ );
+
+ return messages;
+}
+export async function getASRouterLocalColumnFromJSON(
+ messageDef: any,
+): Promise {
+ let fxmsMsgInfo: FxMSMessageInfo = {
+ product: "Desktop",
+ id: messageDef.id,
+ template: messageDef.template,
+ surface: getSurfaceDataForTemplate(getTemplateFromMessage(messageDef))
+ .surface,
+ segment: "some segment",
+ metrics: "some metrics",
+ ctrPercent: undefined, // may be populated from Looker data
+ ctrPercentChange: undefined, // may be populated from Looker data
+ previewLink: getPreviewLink(maybeCreateWelcomePreview(messageDef)),
+ impressions: undefined, // may be populated from Looker data
+ hasMicrosurvey: messageHasMicrosurvey(messageDef.id),
+ hidePreview: messageDef.hidePreview,
+ };
+
+ const channel = "release";
+
+ if (isLookerEnabled) {
+ const ctrPercentData = await getCTRPercentData(
+ fxmsMsgInfo.id,
+ fxmsMsgInfo.template,
+ channel,
+ );
+ if (ctrPercentData) {
+ fxmsMsgInfo.ctrPercent = ctrPercentData.ctrPercent;
+ fxmsMsgInfo.impressions = ctrPercentData.impressions;
+ }
+ }
+
+ fxmsMsgInfo.ctrDashboardLink = getDashboard(
+ fxmsMsgInfo.template,
+ fxmsMsgInfo.id,
+ channel,
+ );
+
+ // dashboard link -> dashboard id -> query id -> query -> ctr_percent_from_lastish_day
+ // console.log("fxmsMsgInfo: ", fxmsMsgInfo)
+ return fxmsMsgInfo;
+}
+
+/**
+ * Appends any FxMS telemetry message data from the query in Look
+ * https://mozilla.cloud.looker.com/looks/2162 that does not already exist (ie.
+ * no duplicate message ids) in existingMessageData and returns the result. The
+ * message data is also cleaned up to match the message data objects from
+ * ASRouter, remove any test messages, and update templates.
+ */
+export async function appendFxMSTelemetryData(existingMessageData: any) {
+ // Get Looker message data (taken from the query in Look
+ // https://mozilla.cloud.looker.com/looks/2162)
+ const lookId = "2162";
+ let lookerData = await runLookQuery(lookId);
+
+ // Clean and merge Looker data with existing data
+ let jsonLookerData = cleanLookerData(lookerData);
+ let mergedData = mergeLookerData(existingMessageData, jsonLookerData);
+
+ return mergedData;
+}
+
+/**
+ * A sorting function to sort messages by their start dates in descending order.
+ * If one or both of the recipes is missing a start date, they will be ordered
+ * identically since there's not enough information to properly sort them by
+ * date.
+ *
+ * @param a Nimbus recipe to compare with `b`.
+ * @param b Nimbus recipe to compare with `a`.
+ * @returns -1 if the start date for message a is after the start date for
+ * message b, zero if they're equal, and 1 otherwise.
+ */
+
+export function compareDatesFn(a: NimbusRecipe, b: NimbusRecipe): number {
+ if (a._rawRecipe.startDate && b._rawRecipe.startDate) {
+ if (a._rawRecipe.startDate > b._rawRecipe.startDate) {
+ return -1;
+ } else if (a._rawRecipe.startDate < b._rawRecipe.startDate) {
+ return 1;
+ }
+ }
+
+ // a must be equal to b
+ return 0;
+}
+
+export async function getMsgRolloutCollection(
+ recipeCollection: NimbusRecipeCollection,
+): Promise {
+ const msgRolloutRecipeCollection = new NimbusRecipeCollection();
+ msgRolloutRecipeCollection.recipes = recipeCollection.recipes
+ .filter((recipe) => recipe.usesMessagingFeatures() && !recipe.isExpRecipe())
+ .sort(compareDatesFn);
+ console.log(
+ "msgRolloutRecipeCollection.length = ",
+ msgRolloutRecipeCollection.recipes.length,
+ );
+
+ return msgRolloutRecipeCollection;
+}
diff --git a/app/page.tsx b/app/page.tsx
index 1bd77d9b..3bbd65d6 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,5 +1,25 @@
import { Dashboard } from "@/app/dashboard";
+import { fetchData } from "@/app/fetchData";
-export default function Page() {
- return ;
+const platform = "firefox-desktop";
+
+export default async function Page() {
+ const {
+ localData,
+ experimentAndBranchInfo,
+ totalExperiments,
+ msgRolloutInfo,
+ totalRolloutExperiments,
+ } = await fetchData(platform);
+
+ return (
+
+ );
}
diff --git a/app/types.ts b/app/types.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/experimentUtils.ts b/lib/experimentUtils.ts
index 9bb2fa71..619202fa 100644
--- a/lib/experimentUtils.ts
+++ b/lib/experimentUtils.ts
@@ -31,6 +31,13 @@ export const MESSAGING_EXPERIMENTS_DEFAULT_FEATURES: string[] = [
"spotlight",
"testFeature",
"whatsNewPage",
+
+ // XXX these should live elsewhere; they are Android features
+ "cfr",
+ "encourage-search-cfr",
+ "messaging",
+ "juno-onboarding",
+ "set-to-default-prompt",
];
/**
diff --git a/lib/messageUtils.ts b/lib/messageUtils.ts
index 11d31eaf..f83a23e1 100644
--- a/lib/messageUtils.ts
+++ b/lib/messageUtils.ts
@@ -113,6 +113,62 @@ export function _isAboutWelcomeTemplate(template: string): boolean {
return aboutWelcomeSurfaces.includes(template);
}
+//mozilla.cloud.looker.com/dashboards/2191?Normalized+Channel=release&Submission+Date=2025%2F02%2F13+to+2025%2F03%2F13&Experiment+Slug=rootca-info-card-hcr1-fenix&Value+Branch=treatment-a&Sample+ID=%3C%3D10&Value=info%5E_card%5E_rootCA%5E_HCR1%25
+
+export function getAndroidDashboard(
+ surface: string,
+ msgIdPrefix: string,
+ channel?: string,
+ experiment?: string,
+ branchSlug?: string,
+ startDate?: string | null,
+ endDate?: string | null,
+ isCompleted?: boolean,
+): string | undefined {
+ // The isCompleted value can be useful for messages that used to be in remote
+ // settings or old versions of Firefox.
+ const submissionDate = getLookerSubmissionTimestampDateFilter(
+ startDate,
+ endDate,
+ isCompleted,
+ );
+
+ const dashboardId = 2191; // messages/push notification
+ // XXXgetDashboardIdForTemplate(surface);
+ let baseUrl = `https://mozilla.cloud.looker.com/dashboards/${dashboardId}`;
+ let paramObj;
+
+ paramObj = {
+ "Submission Date": submissionDate,
+ //"Messaging System Message Id": msgIdPrefix,
+ "Normalized Channel": channel ? channel : "",
+ "Normalized OS": "",
+ "Client Info App Display Version": "",
+ "Normalized Country Code": "",
+ "Experiment Slug": experiment ? experiment : "", // XXX
+ "Experiment Branch": branchSlug ? branchSlug : "",
+ // XXX assumes last part of message id is something like
+ // "-en-us" and chops that off, since we want to know about
+ // all the messages in the experiment. Will break
+ // (in "no results" way) on experiment with messages not configured
+ // like that.
+
+ Value: msgIdPrefix.slice(0, -5) + "%", // XXX
+ };
+
+ // XXX we really handle all messaging surfaces, at least in theory
+ if (surface !== "survey") return undefined;
+
+ if (paramObj) {
+ const params = new URLSearchParams(Object.entries(paramObj));
+ let url = new URL(baseUrl);
+ url.search = params.toString();
+ return url.toString();
+ }
+
+ return undefined;
+}
+
export function getDashboard(
template: string,
msgId: string,
diff --git a/lib/nimbusRecipe.ts b/lib/nimbusRecipe.ts
index 0d0b34c1..aa9e6325 100644
--- a/lib/nimbusRecipe.ts
+++ b/lib/nimbusRecipe.ts
@@ -1,5 +1,6 @@
import { BranchInfo, RecipeInfo, RecipeOrBranchInfo } from "../app/columns.jsx";
import {
+ getAndroidDashboard,
getDashboard,
getSurfaceDataForTemplate,
getPreviewLink,
@@ -78,10 +79,107 @@ export class NimbusRecipe implements NimbusRecipeType {
this._isCompleted = isCompleted;
}
- /**
- * @returns an array of BranchInfo objects, one per branch in this recipe
- */
+ getAndroidBranchInfo(branch: any): BranchInfo {
+ let branchInfo: BranchInfo = {
+ product: "Android",
+ id: branch.slug,
+ isBranch: true,
+ // The raw experiment data can be automatically serialized to
+ // the client by NextJS (but classes can't), and any
+ // needed NimbusRecipe class rewrapping can be done there.
+ nimbusExperiment: this._rawRecipe,
+ slug: branch.slug,
+ screenshots: branch.screenshots,
+ description: branch.description,
+ };
+
+ // XXX need to handle multi branches
+ const feature = branch.features[0];
+
+ switch (feature.featureId) {
+ case "messaging":
+ // console.log("in messaging feature, feature = ", feature);
+
+ // console.log("feature.value = ", feature.value);
+ if (Object.keys(feature.value).length === 0) {
+ console.warn(
+ "empty feature value, returning error, branch.slug = ",
+ branch.slug,
+ );
+ return branchInfo;
+ }
+
+ const message0: any = Object.values(feature.value.messages)[0];
+ const message0Id: string = Object.keys(feature.value.messages)[0];
+ branchInfo.id = message0Id;
+
+ // console.log("message0 = ", message0);
+
+ const surface = message0.surface;
+ // XXX need to rename template & surface somehow
+ branchInfo.template = surface;
+ branchInfo.surface = surface;
+
+ switch (surface) {
+ case "messages":
+ // XXX I don' tthink this a real case
+ console.log("in messages surface case");
+ break;
+
+ case "survey":
+ break;
+
+ default:
+ console.warn("unhandled message surface: ", branchInfo.surface);
+ }
+ break;
+
+ case "juno-onboarding":
+ console.warn(`we don't fully support juno-onboarding messages yet`);
+ break;
+
+ default:
+ console.warn("default hit");
+ console.warn("branch.slug = ", branch.slug);
+ console.warn("We don't support feature = ", feature);
+ // JSON.stringify(branch.features),
+ // );
+ }
+
+ const proposedEndDate = getExperimentLookerDashboardDate(
+ branchInfo.nimbusExperiment.startDate,
+ branchInfo.nimbusExperiment.proposedDuration,
+ );
+ let formattedEndDate;
+ if (branchInfo.nimbusExperiment.endDate) {
+ formattedEndDate = formatDate(branchInfo.nimbusExperiment.endDate, 1);
+ }
+
+ branchInfo.ctrDashboardLink = getAndroidDashboard(
+ branchInfo.surface as string,
+ branchInfo.id,
+ undefined,
+ branchInfo.nimbusExperiment.slug,
+ branch.slug,
+ branchInfo.nimbusExperiment.startDate,
+ branchInfo.nimbusExperiment.endDate ? formattedEndDate : proposedEndDate,
+ this._isCompleted,
+ );
+
+ console.log("Android Dashboard:", branchInfo.ctrDashboardLink);
+
+ return branchInfo;
+ }
getBranchInfo(branch: any): BranchInfo {
+ switch (this._rawRecipe.appName) {
+ case "fenix":
+ return this.getAndroidBranchInfo(branch);
+ default:
+ return this.getDesktopBranchInfo(branch);
+ }
+ }
+
+ getDesktopBranchInfo(branch: any): BranchInfo {
let branchInfo: BranchInfo = {
product: "Desktop",
id: branch.slug,
@@ -246,6 +344,7 @@ export class NimbusRecipe implements NimbusRecipeType {
return branchInfo;
default:
+ //console.log("Hit default case, template = ", template);
if (!feature.value?.messages) {
// console.log("v.messages is null");
// console.log(", feature.value = ", feature.value);
diff --git a/lib/nimbusRecipeCollection.ts b/lib/nimbusRecipeCollection.ts
index 20f19810..096c013e 100644
--- a/lib/nimbusRecipeCollection.ts
+++ b/lib/nimbusRecipeCollection.ts
@@ -2,6 +2,7 @@ import { NimbusRecipe } from "../lib/nimbusRecipe";
import { BranchInfo, RecipeInfo, RecipeOrBranchInfo } from "@/app/columns";
import { getCTRPercentData } from "./looker";
import { getExperimentLookerDashboardDate } from "./lookerUtils";
+import { Platform } from "./types";
const nimbusExperimentV7Schema = require("@mozilla/nimbus-schemas/schemas/NimbusExperimentV7.schema.json");
type NimbusExperiment = typeof nimbusExperimentV7Schema.properties;
@@ -10,6 +11,7 @@ type NimbusRecipeCollectionType = {
recipes: Array;
isCompleted: boolean;
fetchRecipes: () => Promise>;
+ platform: Platform;
};
/**
@@ -47,16 +49,25 @@ async function updateBranchesCTR(recipe: NimbusRecipe): Promise {
export class NimbusRecipeCollection implements NimbusRecipeCollectionType {
recipes: Array;
isCompleted: boolean;
+ platform: Platform;
- constructor(isCompleted: boolean = false) {
+ // XXX XXX remove this default platform, it's a total footgun
+ constructor(
+ isCompleted: boolean = false,
+ platform: Platform = "firefox-desktop",
+ ) {
this.recipes = [];
this.isCompleted = isCompleted;
+ this.platform = platform;
}
async fetchRecipes(): Promise> {
- let experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}${process.env.EXPERIMENTER_API_CALL_LIVE}`;
+ // XXX should really be using URL.parse and URLSearchParams to manage all
+ // this stuff
+ let experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}?status=Live&application=${this.platform}`;
if (this.isCompleted) {
- experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}${process.env.EXPERIMENTER_API_CALL_COMPLETED}`;
+ // XXX rename to isComplete for consistency
+ experimenterUrl = `${process.env.EXPERIMENTER_API_PREFIX}?status=Complete&application=${this.platform}`;
}
// console.log("experimenterURL = ", experimenterUrl)
diff --git a/lib/platformInfo.ts b/lib/platformInfo.ts
new file mode 100644
index 00000000..9b19699a
--- /dev/null
+++ b/lib/platformInfo.ts
@@ -0,0 +1,16 @@
+import { Platform } from "./types";
+interface PlatformInfo {
+ displayName: string;
+}
+
+export const platformDictionary: Record = {
+ fenix: {
+ displayName: "Android",
+ },
+ ios: {
+ displayName: "iOS",
+ },
+ "firefox-desktop": {
+ displayName: "Desktop",
+ },
+};
diff --git a/lib/types.ts b/lib/types.ts
new file mode 100644
index 00000000..68681e99
--- /dev/null
+++ b/lib/types.ts
@@ -0,0 +1,5 @@
+// These are the same strings that the experimenter API uses to determine which
+// endpoint to hit. XXX we should use our own ID and put this in
+// PlatformInfo in case Nimbus changes its strings.
+
+export type Platform = "fenix" | "ios" | "firefox-desktop";