Skip to content

Commit

Permalink
Merge pull request #303 from chromaui/ghengeveld/251-ui-no-network-issue
Browse files Browse the repository at this point in the history
Poll for API connection and show notification when it fails
  • Loading branch information
ghengeveld committed May 15, 2024
2 parents 926e6b1 + 3053a3b commit 714d2f7
Show file tree
Hide file tree
Showing 7 changed files with 153 additions and 8 deletions.
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

0 comments on commit 714d2f7

Please sign in to comment.