diff --git a/src/Panel.tsx b/src/Panel.tsx index 2122d601..d972c750 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -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, @@ -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"; @@ -49,6 +51,7 @@ export const Panel = ({ active, api }: PanelProps) => { ); const { storyId } = useStorybookState(); + const [apiInfo] = useSharedState(API_INFO); const [gitInfo] = useSharedState(GIT_INFO); const [gitInfoError] = useSharedState(GIT_INFO_ERROR); const [isOutdated] = useSharedState(IS_OUTDATED); @@ -111,6 +114,10 @@ export const Panel = ({ active, api }: PanelProps) => { return withProviders(); } + if (apiInfo?.connected === false) { + return withProviders(); + } + // Render the Authentication flow if the user is not signed in. if (!accessToken) { return withProviders( diff --git a/src/SidebarTop.tsx b/src/SidebarTop.tsx index d3e1f3f4..28aca8d5 100644 --- a/src/SidebarTop.tsx +++ b/src/SidebarTop.tsx @@ -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"; @@ -32,6 +33,7 @@ export const SidebarTop = ({ api }: SidebarTopProps) => { const [isOutdated] = useSharedState(IS_OUTDATED); const [localBuildProgress] = useSharedState(LOCAL_BUILD_PROGRESS); + const [apiInfo] = useSharedState(API_INFO); const [configInfo] = useSharedState(CONFIG_INFO); const hasConfigProblem = Object.keys(configInfo?.problems || {}).length > 0; @@ -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."; diff --git a/src/constants.ts b/src/constants.ts index 5333afba..25a9cbe4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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`; @@ -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 diff --git a/src/preset.ts b/src/preset.ts index d884ae22..85b072af 100644 --- a/src/preset.ts +++ b/src/preset.ts @@ -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, @@ -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, @@ -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, @@ -136,7 +169,7 @@ const observeGitInfo = async ( }; act(); - return () => clearTimeout(timer); + return { cancel: () => clearTimeout(timer) }; }; const watchConfigFile = async ( @@ -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(API_INFO, channel); const configInfoState = SharedState.subscribe(CONFIG_INFO, channel); const gitInfoState = SharedState.subscribe(GIT_INFO, channel); const gitInfoError = SharedState.subscribe(GIT_INFO_ERROR, channel); - observeGitInfo( + let apiInfoObserver = observeAPIInfo(5000, (info: APIInfoPayload) => { + apiInfoState.value = info; + }); + + const gitInfoObserver = observeGitInfo( 5000, (info) => { gitInfoError.value = undefined; @@ -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; } diff --git a/src/screens/NoNetwork/NoNetwork.stories.tsx b/src/screens/NoNetwork/NoNetwork.stories.tsx new file mode 100644 index 00000000..3fd11203 --- /dev/null +++ b/src/screens/NoNetwork/NoNetwork.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { NoNetwork } from "./NoNetwork"; + +const meta = { + component: NoNetwork, + args: { + aborted: false, + }, +} satisfies Meta; + +export default meta; + +export const Default = {} satisfies StoryObj; + +export const Aborted = { + args: { + aborted: true, + }, +} satisfies StoryObj; diff --git a/src/screens/NoNetwork/NoNetwork.tsx b/src/screens/NoNetwork/NoNetwork.tsx new file mode 100644 index 00000000..38fcca15 --- /dev/null +++ b/src/screens/NoNetwork/NoNetwork.tsx @@ -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 ( + + + +
+ Can't connect to Chromatic + + Double check your internet connection and firewall settings. + +
+ {aborted ? ( + + ) : ( + + )} + + Chromatic API status + +
+
+
+ ); +}; diff --git a/src/types.ts b/src/types.ts index eb5b8f54..2bd6f9b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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>; problems?: ConfigurationUpdate;