Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions __tests__/app/page.test.tsx → __tests__/app/dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Dashboard from "@/app/page";
import { Dashboard } from "@/app/dashboard";
import CompleteExperimentsDashboard from "@/app/complete/page";
import { render } from "@testing-library/react";
import { ExperimentFakes } from "../ExperimentFakes.mjs";
Expand All @@ -11,17 +11,17 @@ global.fetch = jest.fn(() =>
}),
) as jest.Mock;

describe("Page", () => {
describe.skip("Dashboard", () => {
it("all timeline pill ids exist in the Dashboard component in /", async () => {
const dashboard = render(await Dashboard());
const dashboard = await render(await (<Dashboard />));

const firefox = dashboard.getByTestId("firefox");
const experiments = dashboard.getByTestId("live_experiments");
const rollouts = dashboard.getByTestId("live_rollouts");

expect(firefox).toBeDefined();
expect(experiments).toBeDefined();
expect(rollouts).toBeDefined();
expect(firefox).toBeInTheDocument();
expect(experiments).toBeInTheDocument();
expect(rollouts).toBeInTheDocument();
});

it("all timeline pill ids exist in the Dashboard component in /complete", async () => {
Expand All @@ -30,7 +30,7 @@ describe("Page", () => {
const experiments = dashboard.getByTestId("complete_experiments");
const rollouts = dashboard.getByTestId("complete_rollouts");

expect(experiments).toBeDefined();
expect(rollouts).toBeDefined();
expect(experiments).toBeInTheDocument();
expect(rollouts).toBeInTheDocument();
});
});
5 changes: 5 additions & 0 deletions app/android/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Dashboard } from "@/app/dashboard";

export default function Page() {
return <Dashboard platform={"android"} />;
}
339 changes: 339 additions & 0 deletions app/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
import { types } from "@mozilla/nimbus-shared";
import {
experimentColumns,
FxMSMessageInfo,
fxmsMessageColumns,
} from "./columns";
import {
cleanLookerData,
getCTRPercentData,
mergeLookerData,
runLookQuery,
} from "@/lib/looker.ts";
import {
getDashboard,
getSurfaceDataForTemplate,
getTemplateFromMessage,
_isAboutWelcomeTemplate,
maybeCreateWelcomePreview,
getPreviewLink,
messageHasMicrosurvey,
compareSurfacesFn,
} from "../lib/messageUtils.ts";

import { NimbusRecipeCollection } from "../lib/nimbusRecipeCollection";
import { _substituteLocalizations } from "../lib/experimentUtils.ts";

import { NimbusRecipe } from "../lib/nimbusRecipe.ts";
import { MessageTable } from "./message-table";

import { MenuButton } from "@/components/ui/menubutton.tsx";
import { InfoPopover } from "@/components/ui/infopopover.tsx";
import { Timeline } from "@/components/ui/timeline.tsx";

const isLookerEnabled = process.env.IS_LOOKER_ENABLED === "true";

const hidden_message_impression_threshold =
process.env.HIDDEN_MESSAGE_IMPRESSION_THRESHOLD;

/**
* 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.
*/
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;
}

async function getASRouterLocalColumnFromJSON(
messageDef: any,
): Promise<FxMSMessageInfo> {
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;
}

let columnsShown = false;

type NimbusExperiment = types.experiments.NimbusExperiment;

/**
* 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.
*/
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;
}

/**
* @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.
*/
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<FxMSMessageInfo> => {
return await getASRouterLocalColumnFromJSON(messageDef);
}),
);

return messages;
}

async function getMsgExpRecipeCollection(
recipeCollection: NimbusRecipeCollection,
): Promise<NimbusRecipeCollection> {
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;
}

async function getMsgRolloutCollection(
recipeCollection: NimbusRecipeCollection,
): Promise<NimbusRecipeCollection> {
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;
}

async function fetchData() {
const recipeCollection = new NimbusRecipeCollection();
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,
};
}

interface ReleasedTableProps {
platform: string;
localData: FxMSMessageInfo[];
}

const ReleasedTable = async ({ platform, localData }: ReleasedTableProps) => {
return (
<div>
<h5
id="firefox"
data-testid="firefox"
className="scroll-m-20 text-xl font-semibold text-center pt-6 flex items-center justify-center gap-x-1"
>
{platform} Messages Released on Firefox
<InfoPopover
content="All messages listed in this table are in the release channel and are either currently live or have been live on Firefox at one time."
iconStyle="h-7 w-7 p-1 rounded-full cursor-pointer border-0 bg-slate-100 hover:bg-slate-200"
/>
</h5>
<div className="sticky top-24 z-10 bg-background py-2 flex justify-center">
<Timeline active="firefox" />
</div>

<div className="container mx-auto py-10">
<MessageTable
columns={fxmsMessageColumns}
data={localData}
canHideMessages={true}
impressionsThreshold={hidden_message_impression_threshold}
/>
</div>
</div>
);
};

interface DashboardProps {
platform?: string;
}

export const Dashboard = async (
{ platform }: DashboardProps = { platform: "desktop" },
) => {
const {
localData,
experimentAndBranchInfo,
totalExperiments,
msgRolloutInfo,
totalRolloutExperiments,
} = await fetchData();

return (
<div role="main" data-testid="dashboard">
<div className="sticky top-0 z-50 bg-background flex justify-between px-20 py-8">
<h4 className="scroll-m-20 text-3xl font-semibold">Skylight</h4>
<MenuButton isComplete={false} />
</div>

<ReleasedTable platform={platform as string} localData={localData} />

<h5 className="scroll-m-20 text-xl font-semibold text-center pt-4">
Current {platform} Message Rollouts
</h5>
<h5
id="live_rollouts"
data-testid="live_rollouts"
className="scroll-m-20 text-sm text-center"
>
Total: {totalRolloutExperiments}
</h5>
<div className="sticky top-24 z-10 bg-background py-2 flex justify-center">
<Timeline active="rollout" />
</div>
<div className="container mx-auto py-10">
<MessageTable
columns={experimentColumns}
data={msgRolloutInfo}
defaultExpanded={true}
/>
</div>

<h5 className="scroll-m-20 text-xl font-semibold text-center pt-4">
Current {platform} Message Experiments
</h5>
<h5
id="live_experiments"
data-testid="live_experiments"
className="scroll-m-20 text-sm text-center"
>
Total: {totalExperiments}
</h5>
<div className="sticky top-24 z-10 bg-background py-2 flex justify-center">
<Timeline active="experiment" />
</div>
<div className="space-y-5 container mx-auto py-10">
<MessageTable
columns={experimentColumns}
data={experimentAndBranchInfo}
defaultExpanded={true}
/>
</div>
</div>
);
};
Loading