Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Poll for API connection and show notification when it fails #303

Merged
merged 7 commits into from
May 15, 2024
9 changes: 8 additions & 1 deletion src/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AuthProvider } from "./AuthContext";
import { Spinner } from "./components/design-system";
import {
ADDON_ID,
API_INFO,
GIT_INFO,
GIT_INFO_ERROR,
IS_OUTDATED,
Expand All @@ -20,12 +21,13 @@ import { LinkedProject } from "./screens/LinkProject/LinkedProject";
import { LinkingProjectFailed } from "./screens/LinkProject/LinkingProjectFailed";
import { LinkProject } from "./screens/LinkProject/LinkProject";
import { NoDevServer } from "./screens/NoDevServer/NoDevServer";
import { NoNetwork } from "./screens/NoNetwork/NoNetwork";
import { UninstallProvider } from "./screens/Uninstalled/UninstallContext";
import { Uninstalled } from "./screens/Uninstalled/Uninstalled";
import { ControlsProvider } from "./screens/VisualTests/ControlsContext";
import { RunBuildProvider } from "./screens/VisualTests/RunBuildContext";
import { VisualTests } from "./screens/VisualTests/VisualTests";
import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "./types";
import { APIInfoPayload, GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "./types";
import { client, Provider, useAccessToken } from "./utils/graphQLClient";
import { TelemetryProvider } from "./utils/TelemetryContext";
import { useBuildEvents } from "./utils/useBuildEvents";
Expand All @@ -49,6 +51,7 @@ export const Panel = ({ active, api }: PanelProps) => {
);
const { storyId } = useStorybookState();

const [apiInfo] = useSharedState<APIInfoPayload>(API_INFO);
const [gitInfo] = useSharedState<GitInfoPayload>(GIT_INFO);
const [gitInfoError] = useSharedState<Error>(GIT_INFO_ERROR);
const [isOutdated] = useSharedState<boolean>(IS_OUTDATED);
Expand Down Expand Up @@ -111,6 +114,10 @@ export const Panel = ({ active, api }: PanelProps) => {
return withProviders(<Uninstalled />);
}

if (apiInfo?.connected === false) {
return withProviders(<NoNetwork aborted={apiInfo.aborted} />);
}

// Render the Authentication flow if the user is not signed in.
if (!accessToken) {
return withProviders(
Expand Down
5 changes: 4 additions & 1 deletion src/SidebarTop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import React, { useCallback, useEffect, useRef } from "react";
import { SidebarTopButton } from "./components/SidebarTopButton";
import {
ADDON_ID,
API_INFO,
CONFIG_INFO,
GIT_INFO_ERROR,
IS_OUTDATED,
LOCAL_BUILD_PROGRESS,
PANEL_ID,
} from "./constants";
import { ConfigInfoPayload, LocalBuildProgress } from "./types";
import { APIInfoPayload, ConfigInfoPayload, LocalBuildProgress } from "./types";
import { useAccessToken } from "./utils/graphQLClient";
import { useBuildEvents } from "./utils/useBuildEvents";
import { useProjectId } from "./utils/useProjectId";
Expand All @@ -32,6 +33,7 @@ export const SidebarTop = ({ api }: SidebarTopProps) => {
const [isOutdated] = useSharedState<boolean>(IS_OUTDATED);
const [localBuildProgress] = useSharedState<LocalBuildProgress>(LOCAL_BUILD_PROGRESS);

const [apiInfo] = useSharedState<APIInfoPayload>(API_INFO);
const [configInfo] = useSharedState<ConfigInfoPayload>(CONFIG_INFO);
const hasConfigProblem = Object.keys(configInfo?.problems || {}).length > 0;

Expand Down Expand Up @@ -173,6 +175,7 @@ export const SidebarTop = ({ api }: SidebarTopProps) => {
const { isRunning, startBuild, stopBuild } = useBuildEvents({ localBuildProgress, accessToken });

let warning;
if (apiInfo?.connected === false) warning = "Visual tests locked while waiting for network.";
if (!projectId) warning = "Visual tests locked until a project is selected.";
if (!isLoggedIn) warning = "Visual tests locked until you are logged in.";
if (gitInfoError) warning = "Visual tests locked due to Git synchronization problem.";
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const SIDEBAR_TOP_ID = `${ADDON_ID}/sidebarTop`;
export const SIDEBAR_BOTTOM_ID = `${ADDON_ID}/sidebarBottom`;
export const ACCESS_TOKEN_KEY = `${ADDON_ID}/access-token/${CHROMATIC_BASE_URL}`;
export const DEV_BUILD_ID_KEY = `${ADDON_ID}/dev-build-id`;
export const API_INFO = `${ADDON_ID}/apiInfo`;
export const CONFIG_INFO = `${ADDON_ID}/configInfo`;
export const CONFIG_INFO_DISMISSED = `${ADDON_ID}/configInfoDismissed`;
export const GIT_INFO = `${ADDON_ID}/gitInfo`;
Expand All @@ -26,6 +27,7 @@ export const SELECTED_BROWSER_ID = `${ADDON_ID}/selectedBrowserId`;
export const TELEMETRY = `${ADDON_ID}/telemetry`;

export const REMOVE_ADDON = `${ADDON_ID}/removeAddon`;
export const RETRY_CONNECTION = `${ADDON_ID}/retryConnection`;

export const CONFIG_OVERRIDES = {
// Local changes should never be auto-accepted
Expand Down
60 changes: 54 additions & 6 deletions src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { type Configuration, getConfiguration, getGitInfo, type GitInfo } from "

import {
ADDON_ID,
API_INFO,
CHROMATIC_API_URL,
CHROMATIC_BASE_URL,
CONFIG_INFO,
GIT_INFO,
Expand All @@ -19,12 +21,14 @@ import {
PACKAGE_NAME,
PROJECT_INFO,
REMOVE_ADDON,
RETRY_CONNECTION,
START_BUILD,
STOP_BUILD,
TELEMETRY,
} from "./constants";
import { runChromaticBuild, stopChromaticBuild } from "./runChromaticBuild";
import {
APIInfoPayload,
ConfigInfoPayload,
ConfigurationUpdate,
GitInfoPayload,
Expand Down Expand Up @@ -104,9 +108,38 @@ const getConfigInfo = async (
};
};

// Polls for a connection to the Chromatic API.
// Uses a recursive setTimeout instead of setInterval to avoid overlapping async calls.
// Two consecutive failures are needed before considering the connection as lost.
// Retries with an increasing delay after the first failure and aborts after 10 attempts.
const observeAPIInfo = (interval: number, callback: (apiInfo: APIInfoPayload) => void) => {
let timer: NodeJS.Timeout | undefined;
const act = async (attempt = 1) => {
if (attempt > 10) {
callback({ aborted: true, connected: false });
return;
}
const ok = await fetch(CHROMATIC_API_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: `{ viewer { id } }` }),
}).then(
(res) => res.ok,
() => false
);
if (ok || attempt > 1) {
callback({ aborted: false, connected: ok });
}
timer = ok ? setTimeout(act, interval) : setTimeout(act, attempt * 1000, attempt + 1);
};
act();

return { cancel: () => clearTimeout(timer) };
};

// Polls for changes to the Git state and invokes the callback when it changes.
// Uses a recursive setTimeout instead of setInterval to avoid overlapping async calls.
const observeGitInfo = async (
const observeGitInfo = (
interval: number,
callback: (info: GitInfo, prevInfo?: GitInfo) => void,
errorCallback: (e: Error) => void,
Expand Down Expand Up @@ -136,7 +169,7 @@ const observeGitInfo = async (
};
act();

return () => clearTimeout(timer);
return { cancel: () => clearTimeout(timer) };
};

const watchConfigFile = async (
Expand Down Expand Up @@ -211,20 +244,22 @@ async function serverChannel(channel: Channel, options: Options & { configFile?:
});

channel.on(STOP_BUILD, stopChromaticBuild);
channel.on(REMOVE_ADDON, () =>
apiPromise.then((api) => api.removeAddon(PACKAGE_NAME)).catch((e) => console.error(e))
);

channel.on(TELEMETRY, async (event: Event) => {
if ((await corePromise).disableTelemetry) return;
telemetry("addon-visual-tests" as any, { ...event, addonVersion: await getAddonVersion() });
});

const apiInfoState = SharedState.subscribe<APIInfoPayload>(API_INFO, channel);
const configInfoState = SharedState.subscribe<ConfigInfoPayload>(CONFIG_INFO, channel);
const gitInfoState = SharedState.subscribe<GitInfoPayload>(GIT_INFO, channel);
const gitInfoError = SharedState.subscribe<Error>(GIT_INFO_ERROR, channel);

observeGitInfo(
let apiInfoObserver = observeAPIInfo(5000, (info: APIInfoPayload) => {
apiInfoState.value = info;
});

const gitInfoObserver = observeGitInfo(
5000,
(info) => {
gitInfoError.value = undefined;
Expand All @@ -242,6 +277,19 @@ async function serverChannel(channel: Channel, options: Options & { configFile?:

setInterval(() => channel.emit(`${ADDON_ID}/heartbeat`), 1000);

channel.on(REMOVE_ADDON, () => {
apiPromise.then((api) => api.removeAddon(PACKAGE_NAME)).catch((e) => console.error(e));
apiInfoObserver.cancel();
gitInfoObserver.cancel();
});

channel.on(RETRY_CONNECTION, () => {
apiInfoObserver.cancel();
apiInfoObserver = observeAPIInfo(5000, (info: APIInfoPayload) => {
apiInfoState.value = info;
});
});

return channel;
}

Expand Down
20 changes: 20 additions & 0 deletions src/screens/NoNetwork/NoNetwork.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";

import { NoNetwork } from "./NoNetwork";

const meta = {
component: NoNetwork,
args: {
aborted: false,
},
} satisfies Meta<typeof NoNetwork>;

export default meta;

export const Default = {} satisfies StoryObj<typeof meta>;

export const Aborted = {
args: {
aborted: true,
},
} satisfies StoryObj<typeof meta>;
61 changes: 61 additions & 0 deletions src/screens/NoNetwork/NoNetwork.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SyncIcon } from "@storybook/icons";
import { useChannel } from "@storybook/manager-api";
import { styled } from "@storybook/theming";
import React, { useEffect, useState } from "react";

import { Button } from "../../components/Button";
import { Container } from "../../components/Container";
import { Link } from "../../components/design-system";
import { rotate360 } from "../../components/design-system/shared/animation";
import { Heading } from "../../components/Heading";
import { Screen } from "../../components/Screen";
import { Stack } from "../../components/Stack";
import { Text } from "../../components/Text";
import { RETRY_CONNECTION } from "../../constants";

const SpinIcon = styled(SyncIcon)({
animation: `${rotate360} 1s linear infinite`,
});

export const NoNetwork = ({ aborted }: { aborted: boolean }) => {
const [retried, setRetried] = useState(false);
const emit = useChannel({});

const retry = () => {
setRetried(true);
emit(RETRY_CONNECTION);
};

useEffect(() => {
setRetried(false);
}, [aborted]);

return (
<Screen footer={null}>
<Container>
<Stack>
<div>
<Heading>Can't connect to Chromatic</Heading>
<Text center muted>
Double check your internet connection and firewall settings.
</Text>
</div>
{aborted ? (
<Button size="medium" variant="solid" onClick={retry} disabled={retried}>
<SyncIcon />
Retry
</Button>
) : (
<Button size="medium" variant="ghost" onClick={retry} disabled={retried}>
<SpinIcon />
Connecting...
</Button>
)}
<Link href="https://status.chromatic.com" target="_blank" rel="noreferrer" withArrow>
Chromatic API status
</Link>
</Stack>
</Container>
</Screen>
);
};
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export type ConfigurationUpdate = {
[Property in keyof Configuration]: Configuration[Property] | null;
};

export type APIInfoPayload = {
aborted: boolean;
connected: boolean;
};
export type ConfigInfoPayload = {
configuration: Awaited<ReturnType<typeof getConfiguration>>;
problems?: ConfigurationUpdate;
Expand Down
Loading