Skip to content

Commit

Permalink
feat: switch and parallel running for DUP logic
Browse files Browse the repository at this point in the history
Introduces the concept of a "variant", which is an alternate candidate
generator for an app ID. Also introduces a variant for DUPs which will
be used to build and test the new departures logic. For now, this only
generates a set of placeholder elements.

Variants can be enabled via:

* Adding a `variant=NAME` query param to a screen URL. The resulting
  screen page will fetch and display data for only that variant.

* Enabling the "variant switcher" in the admin Inspector. This allows
  toggling between variants instantly without refreshing data; the data
  hook uses a special `all` value to request and hold onto the data for
  all variants at once.

Additionally, `ScreenData` functions have an option to run and serialize
all variants that exist for the app ID in parallel, in the background,
without waiting for or doing anything with the results. This means we
can have all existing screens constantly running the new logic at every
step of the implementation, giving us more confidence that the final
rollout would go smoothly. For now, we only use this option on "normal"
screen requests (not pending, not simulation).
  • Loading branch information
digitalcora committed Sep 13, 2024
1 parent c7b2a35 commit 5a8700f
Show file tree
Hide file tree
Showing 18 changed files with 538 additions and 119 deletions.
2 changes: 2 additions & 0 deletions assets/src/apps/v2/dup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MappingContext } from "Components/v2/widget";
import NormalScreen, {
NormalSimulation,
} from "Components/v2/dup/normal_screen";
import Placeholder from "Components/v2/placeholder";
import NormalHeader from "Components/v2/dup/normal_header";
import Departures from "Components/v2/dup/departures";
import MultiScreenPage from "Components/v2/multi_screen_page";
Expand Down Expand Up @@ -53,6 +54,7 @@ const TYPE_TO_COMPONENT = {
body_split_zero: splitRotationFromPropNames(SplitBody, "zero"),
body_split_one: splitRotationFromPropNames(SplitBody, "one"),
body_split_two: splitRotationFromPropNames(SplitBody, "two"),
placeholder: Placeholder,
normal_header: NormalHeader,
departures: Departures,
evergreen_content: EvergreenContent,
Expand Down
119 changes: 95 additions & 24 deletions assets/src/components/admin/inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const AUDIO_SCREEN_TYPES = new Set([
"pre_fare_v2",
]);

const SCREEN_TYPE_VARIANTS = { dup_v2: ["new_departures"] };

const Inspector: ComponentType = () => {
const [config, setConfig] = useState<Config | null>(null);

Expand All @@ -55,6 +57,7 @@ const Inspector: ComponentType = () => {
: null;

const [isSimulation, setIsSimulation] = useState(false);
const [isVariantEnabled, setIsVariantEnabled] = useState(false);

const frameRef = useRef<HTMLIFrameElement>(null);

Expand All @@ -79,13 +82,22 @@ const Inspector: ComponentType = () => {
screen={screen}
isSimulation={isSimulation}
setIsSimulation={setIsSimulation}
isVariantEnabled={isVariantEnabled}
setIsVariantEnabled={setIsVariantEnabled}
/>

{screen && (
<>
<ConfigControls screen={screen} />
<ViewControls zoom={zoom} setZoom={setZoom} />
<DataControls sendToFrame={sendToFrame} />
<DataControls
// Reset when the loaded screen changes, since the new screen
// will not be aware of previously-sent inspector messages
key={screen.id}
isVariantEnabled={isVariantEnabled}
screen={screen}
sendToFrame={sendToFrame}
/>
<AudioControls screen={screen} />
</>
)}
Expand All @@ -101,7 +113,11 @@ const Inspector: ComponentType = () => {
src={
screen
? new URL(
`/v2/screen/${screen.id}${isSimulation ? "/simulation" : ""}`,
[
`/v2/screen/${screen.id}`,
isSimulation ? "/simulation" : "",
isVariantEnabled ? "?variant=all" : "",
].join(""),
location.origin,
).toString()
: "about:blank"
Expand All @@ -117,7 +133,16 @@ const ScreenSelector: ComponentType<{
screen: ScreenWithId | null;
isSimulation: boolean;
setIsSimulation: (value: boolean) => void;
}> = ({ config, screen, isSimulation, setIsSimulation }) => {
isVariantEnabled: boolean;
setIsVariantEnabled: (value: boolean) => void;
}> = ({
config,
screen,
isSimulation,
setIsSimulation,
isVariantEnabled,
setIsVariantEnabled,
}) => {
const history = useHistory();
const { pathname, search } = useLocation();

Expand Down Expand Up @@ -165,7 +190,16 @@ const ScreenSelector: ComponentType<{
checked={isSimulation}
onChange={() => setIsSimulation(!isSimulation)}
/>
Screenplay Simulation
Screenplay simulation
</label>

<label>
<input
type="checkbox"
checked={isVariantEnabled}
onChange={() => setIsVariantEnabled(!isVariantEnabled)}
/>
Enable variant switcher
</label>
</fieldset>
);
Expand Down Expand Up @@ -253,11 +287,14 @@ const ViewControls: ComponentType<{
};

const DataControls: ComponentType<{
isVariantEnabled: boolean;
screen: ScreenWithId;
sendToFrame: (message: Message) => void;
}> = ({ sendToFrame }) => {
}> = ({ isVariantEnabled, screen, sendToFrame }) => {
const [dataTimestamp, setDataTimestamp] = useState<number | null>(null);
const [dataSecondsOld, setDataSecondsOld] = useState<number | null>(null);
const [isRefreshEnabled, setIsRefreshEnabled] = useState(true);
const [variant, setVariant] = useState<string | null>(null);

useReceiveMessage((message) => {
if (message.type == "data_refreshed") {
Expand All @@ -279,29 +316,63 @@ const DataControls: ComponentType<{
sendToFrame({ type: "set_refresh_rate", ms: isRefreshEnabled ? null : 0 });
}, [isRefreshEnabled]);

useEffect(() => {
sendToFrame({ type: "set_data_variant", variant: variant });
}, [variant]);

return (
<fieldset>
<legend>Data</legend>
<>
<fieldset>
<legend>Data</legend>

<div>
<button onClick={() => sendToFrame({ type: "refresh_data" })}>
Refresh
</button>
<div>
<button onClick={() => sendToFrame({ type: "refresh_data" })}>
Refresh
</button>

{isRefreshEnabled && dataSecondsOld != null && (
<span>⏱️ {dataSecondsOld} seconds ago</span>
)}
</div>
{isRefreshEnabled && dataSecondsOld != null && (
<span>⏱️ {dataSecondsOld} seconds ago</span>
)}
</div>

<label>
<input
type="checkbox"
checked={isRefreshEnabled}
onChange={() => setIsRefreshEnabled(!isRefreshEnabled)}
/>
Enable refresh interval
</label>
</fieldset>
<label>
<input
type="checkbox"
checked={isRefreshEnabled}
onChange={() => setIsRefreshEnabled(!isRefreshEnabled)}
/>
Enable refresh interval
</label>
</fieldset>

{isVariantEnabled && (
<fieldset>
<legend>Variants</legend>

<label>
<input
type="radio"
name="variant"
checked={variant === null}
onChange={() => setVariant(null)}
/>
Default
</label>

{(SCREEN_TYPE_VARIANTS[screen.config.app_id] ?? []).map((v) => (
<label key={v}>
<input
type="radio"
name="variant"
checked={variant === v}
onChange={() => setVariant(v)}
/>
<code>{v}</code>
</label>
))}
</fieldset>
)}
</>
);
};

Expand Down
112 changes: 85 additions & 27 deletions assets/src/hooks/v2/use_api_response.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,49 +17,70 @@ const OUTFRONT_BASE_URI = "https://screens.mbta.com";

type SimulationResponse = { full_page: WidgetData; flex_zone: WidgetData[] };

type RawResponse = {
data: SimulationResponse | WidgetData | null;
disabled: boolean;
force_reload: boolean;
};
type DataResponse<T extends SimulationResponse | WidgetData> = {
data: T;
variants?: Record<string, T>
}

type SimulationData = { fullPage: WidgetData; flexZone: WidgetData[] };

type ApiResponse =
// The request was successful.
| { state: "success"; data: WidgetData }
| { state: "simulation_success"; data: SimulationData }
type Success = { state: "success"; data: WidgetData };
type SimulationSuccess = { state: "simulation_success"; data: SimulationData };
type NonSuccess =
// The request was successful, but this screen is disabled via config.
| { state: "disabled" }
// Either:
// - The request failed.
// - The server responded, but did not successfully fetch data. Riders may
// still be able to find data from other sources.
| { state: "failure" }
// Initial state when no data has been received yet.
| { state: "loading" };

type ApiResponse = Success | SimulationSuccess | NonSuccess;

type ApiResponseWithVariants =
| (Success & { variants?: Record<string, WidgetData> })
| (SimulationSuccess & { variants?: Record<string, SimulationData> })
| NonSuccess;

const FAILURE_RESPONSE: ApiResponse = { state: "failure" };
const LOADING_RESPONSE: ApiResponse = { state: "loading" };

const rawResponseToApiResponse = (response: RawResponse): ApiResponse => {
if (response.disabled) {
const parseRawResponse = (json): ApiResponseWithVariants => {
if (json.disabled) {
return { state: "disabled" };
} else if (response.data) {
const data = response.data;
} else if (json.data) {
if ("full_page" in json.data) {
const { data, variants } = json as DataResponse<SimulationResponse>;

if ("full_page" in data) {
return {
state: "simulation_success",
data: { fullPage: data.full_page, flexZone: data.flex_zone },
data: parseSimulationResponse(data),
variants: Object.fromEntries(
Object.entries(variants ?? {}).map(([variant, data]) => [
variant,
parseSimulationResponse(data),
]),
),
};
} else {
return { state: "success", data };
const { data, variants } = json as DataResponse<WidgetData>;
return { state: "success", data, variants };
}
} else {
return { state: "failure" };
}
};

const parseSimulationResponse = ({
full_page,
flex_zone,
}: SimulationResponse): SimulationData => ({
fullPage: full_page,
flexZone: flex_zone,
});

const doFailureBuffer = (
lastSuccess: number | null,
setApiResponse: React.Dispatch<React.SetStateAction<ApiResponse>>,
Expand Down Expand Up @@ -87,8 +108,9 @@ const doFailureBuffer = (
}
};

const isSuccess = (response: ApiResponse) =>
response != null &&
const isSuccess = (
response: ApiResponse,
): response is Success | SimulationSuccess =>
["success", "simulation_success"].includes(response.state);

const loggingParams = () => {
Expand Down Expand Up @@ -127,6 +149,7 @@ const useApiPath = (screenId: string, appendPath?: string): string => {
requestor:
getDatasetValue("requestor") ?? (isRealScreen() ? "real_screen" : null),
screen_side: getScreenSide(),
variant: getDatasetValue("variant"),
...loggingParams(),
};

Expand All @@ -152,28 +175,27 @@ const useBaseApiResponse = (
const [apiResponse, setApiResponse] = useState<ApiResponse>(LOADING_RESPONSE);
const [requestCount, setRequestCount] = useState<number>(0);
const [lastSuccess, setLastSuccess] = useState<number | null>(null);
const variant = useInspectorVariant();
const apiPath = useApiPath(id, appendPath);

const fetchData = async () => {
try {
const now = Date.now();
const result = await fetch(apiPath);
const rawResponse: RawResponse = await result.json();
const json = await result.json();

if (rawResponse.force_reload) {
window.location.reload();
}
if (json.force_reload) window.location.reload();

const apiResponse = rawResponseToApiResponse(rawResponse);
const response = parseRawResponse(json);

if (apiResponse.state == "failure") {
doFailureBuffer(lastSuccess, setApiResponse, apiResponse);
if (response.state == "failure") {
doFailureBuffer(lastSuccess, setApiResponse, response);
} else {
setApiResponse((prevApiResponse) => {
if (!isSuccess(prevApiResponse)) {
SentryLogger.info("Exiting no-data state.");
}
return apiResponse;
return response;
});
setLastSuccess(now);
}
Expand All @@ -200,7 +222,43 @@ const useBaseApiResponse = (

useInspectorControls(fetchData, lastSuccess);

return { apiResponse, requestCount, lastSuccess };
return {
apiResponse: selectVariant(apiResponse, variant),
requestCount,
lastSuccess,
};
};

const selectVariant = (
response: ApiResponseWithVariants,
variant: string | null,
): ApiResponse => {
if (variant && isSuccess(response) && response.variants) {
if (variant in response.variants) {
// This seems like it should be replacable with a less "mutable" approach
// such as `return { ...response, data: response.variants[variant] }`, but
// the compiler can't work out that the types are compatible. Maybe check
// this again once we upgrade to TypeScript 5.
const copy = { ...response };
copy.data = response.variants[variant];
delete copy.variants;
return copy;
} else {
return FAILURE_RESPONSE;
}
} else {
return response;
}
};

const useInspectorVariant = (): string | null => {
const [variant, setVariant] = useState<string | null>(null);

useReceiveFromInspector((message) => {
if (message.type == "set_data_variant") setVariant(message.variant);
});

return variant;
};

const useInspectorControls = (
Expand Down
Loading

0 comments on commit 5a8700f

Please sign in to comment.