diff --git a/airbyte-webapp/.gitignore b/airbyte-webapp/.gitignore index 970b7323e235..10ee29f524a0 100644 --- a/airbyte-webapp/.gitignore +++ b/airbyte-webapp/.gitignore @@ -33,6 +33,9 @@ yarn-error.log* storybook-static/ +# Generated by our build-info plugin +/public/buildInfo.json + # Ignore generated API clients, since they're automatically generated /src/core/request/AirbyteClient.ts /src/core/request/ConnectorBuilderClient.ts diff --git a/airbyte-webapp/packages/vite-plugins/build-info.ts b/airbyte-webapp/packages/vite-plugins/build-info.ts new file mode 100644 index 000000000000..44e31c105128 --- /dev/null +++ b/airbyte-webapp/packages/vite-plugins/build-info.ts @@ -0,0 +1,26 @@ +import type { Plugin } from "vite"; + +import fs from "fs"; +import path from "path"; + +import { v4 as uuidV4 } from "uuid"; + +const buildHash = uuidV4(); + +/** + * A Vite plugin that will generate on every build a new random UUID and write that to the `/buildInfo.json` + * file as well as make it available as `process.env.BUILD_HASH` in code. + */ +export function buildInfo(): Plugin { + return { + name: "airbyte/build-info", + buildStart() { + fs.writeFileSync(path.resolve(__dirname, "../../public/buildInfo.json"), JSON.stringify({ build: buildHash })); + }, + config: () => ({ + define: { + "process.env.BUILD_HASH": JSON.stringify(buildHash), + }, + }), + }; +} diff --git a/airbyte-webapp/packages/vite-plugins/index.ts b/airbyte-webapp/packages/vite-plugins/index.ts index 3ae2f7daddb8..6dd797286257 100644 --- a/airbyte-webapp/packages/vite-plugins/index.ts +++ b/airbyte-webapp/packages/vite-plugins/index.ts @@ -1 +1,2 @@ export { docMiddleware } from "./doc-middleware"; +export { buildInfo } from "./build-info"; diff --git a/airbyte-webapp/src/components/ui/Toast/Toast.module.scss b/airbyte-webapp/src/components/ui/Toast/Toast.module.scss index 36a827682585..1a48e4887a3c 100644 --- a/airbyte-webapp/src/components/ui/Toast/Toast.module.scss +++ b/airbyte-webapp/src/components/ui/Toast/Toast.module.scss @@ -88,10 +88,6 @@ $toast-bottom-margin: 27px; text-align: left; } -.actionButton { - margin-top: variables.$spacing-xs; -} - .closeButton { svg { color: colors.$dark-blue-900; diff --git a/airbyte-webapp/src/components/ui/Toast/Toast.tsx b/airbyte-webapp/src/components/ui/Toast/Toast.tsx index 77fcdbf94845..20030f261c61 100644 --- a/airbyte-webapp/src/components/ui/Toast/Toast.tsx +++ b/airbyte-webapp/src/components/ui/Toast/Toast.tsx @@ -22,6 +22,7 @@ export interface ToastProps { onAction?: () => void; actionBtnText?: string; onClose?: () => void; + "data-testid"?: string; } const ICON_MAPPING = { @@ -38,9 +39,16 @@ const STYLES_BY_TYPE: Readonly> = { [ToastType.INFO]: styles.info, }; -export const Toast: React.FC = ({ type = ToastType.INFO, onAction, actionBtnText, onClose, text }) => { +export const Toast: React.FC = ({ + type = ToastType.INFO, + onAction, + actionBtnText, + onClose, + text, + "data-testid": testId, +}) => { return ( -
+
@@ -52,7 +60,7 @@ export const Toast: React.FC = ({ type = ToastType.INFO, onAction, a )}
{onAction && ( - )} diff --git a/airbyte-webapp/src/hooks/services/Notification/NotificationService.tsx b/airbyte-webapp/src/hooks/services/Notification/NotificationService.tsx index 6fe31fd20c70..41d55d9d31bf 100644 --- a/airbyte-webapp/src/hooks/services/Notification/NotificationService.tsx +++ b/airbyte-webapp/src/hooks/services/Notification/NotificationService.tsx @@ -35,6 +35,9 @@ export const NotificationService = React.memo(({ children }: { children: React.R { + id: string; nonClosable?: boolean; } diff --git a/airbyte-webapp/src/hooks/services/useBuildUpdateCheck.ts b/airbyte-webapp/src/hooks/services/useBuildUpdateCheck.ts new file mode 100644 index 000000000000..85645f6acc2f --- /dev/null +++ b/airbyte-webapp/src/hooks/services/useBuildUpdateCheck.ts @@ -0,0 +1,57 @@ +import { useEffect } from "react"; +import { useIntl } from "react-intl"; +import { fromEvent, interval, merge, throttleTime } from "rxjs"; + +import { useAppMonitoringService } from "./AppMonitoringService"; +import { useNotificationService } from "./Notification"; + +interface BuildInfo { + build: string; +} + +const INTERVAL = 60_000; +const MINIMUM_WAIT_BEFORE_REFETCH = 10_000; + +/** + * This hook sets up a check to /buildInfo.json, which is generated by the build system on every build + * with a new hash. If it ever detects a new hash in it, we know that the Airbyte instance got updated + * and show a notification to the user to reload the page. + */ +export const useBuildUpdateCheck = () => { + const { formatMessage } = useIntl(); + const { registerNotification } = useNotificationService(); + const { trackError } = useAppMonitoringService(); + + useEffect(() => { + // Trigger the check every ${INTERVAL} milliseconds or whenever the window regains focus again + const subscription = merge(interval(INTERVAL), fromEvent(window, "focus")) + // Throttle it to maximum once every 10 seconds + .pipe(throttleTime(MINIMUM_WAIT_BEFORE_REFETCH)) + .subscribe(async () => { + try { + // Fetch the buildInfo.json file without using any browser cache + const buildInfo: BuildInfo = await fetch("/buildInfo.json", { cache: "no-store" }).then((resp) => + resp.json() + ); + + if (buildInfo.build !== process.env.BUILD_HASH) { + registerNotification({ + id: "webapp-updated", + text: formatMessage({ id: "notifications.airbyteUpdated" }), + nonClosable: true, + actionBtnText: formatMessage({ id: "notifications.airbyteUpdated.reload" }), + onAction: () => window.location.reload(), + }); + } + } catch (e) { + // We ignore all errors from the build update check, since it's an optional check to + // inform the user. We don't want to treat failed requests here as errors. + trackError(e); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [formatMessage, registerNotification, trackError]); +}; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index ecee40c62b4e..2fee1d46ceb1 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -3,6 +3,8 @@ "notifications.error.health": "Cannot reach server", "notifications.error.somethingWentWrong": "Something went wrong", "notifications.error.noMessage": "No error message", + "notifications.airbyteUpdated": "Airbyte has been updated. Please reload the page.", + "notifications.airbyteUpdated.reload": "Reload now", "sidebar.homepage": "Homepage", "sidebar.sources": "Sources", diff --git a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx index 411721fbb5ac..b0345c37a8d1 100644 --- a/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx +++ b/airbyte-webapp/src/packages/cloud/cloudRoutes.tsx @@ -8,6 +8,7 @@ import LoadingPage from "components/LoadingPage"; import { useAnalyticsIdentifyUser, useAnalyticsRegisterValues } from "hooks/services/Analytics/useAnalyticsService"; import { FeatureItem, FeatureSet, useFeatureService } from "hooks/services/Feature"; import { useApiHealthPoll } from "hooks/services/Health"; +import { useBuildUpdateCheck } from "hooks/services/useBuildUpdateCheck"; import { useQuery } from "hooks/useQuery"; import { useAuthService } from "packages/cloud/services/auth/AuthService"; import { useCurrentWorkspace, WorkspaceServiceProvider } from "services/workspaces/WorkspacesService"; @@ -117,6 +118,8 @@ export const Routing: React.FC = () => { const { search } = useLocation(); + useBuildUpdateCheck(); + useEffectOnce(() => { setSegmentAnonymousId(search); }); diff --git a/airbyte-webapp/src/pages/routes.tsx b/airbyte-webapp/src/pages/routes.tsx index 1bd7fa696d6f..4eb51539070c 100644 --- a/airbyte-webapp/src/pages/routes.tsx +++ b/airbyte-webapp/src/pages/routes.tsx @@ -5,6 +5,7 @@ import { ApiErrorBoundary } from "components/common/ApiErrorBoundary"; import { useAnalyticsIdentifyUser, useAnalyticsRegisterValues } from "hooks/services/Analytics"; import { useApiHealthPoll } from "hooks/services/Health"; +import { useBuildUpdateCheck } from "hooks/services/useBuildUpdateCheck"; import { useCurrentWorkspace } from "hooks/services/useWorkspace"; import { useListWorkspaces } from "services/workspaces/WorkspacesService"; import { CompleteOauthRequest } from "views/CompleteOauthRequest"; @@ -92,6 +93,8 @@ const RoutingWithWorkspace: React.FC<{ element?: JSX.Element }> = ({ element }) }; export const Routing: React.FC = () => { + useBuildUpdateCheck(); + // TODO: Remove this after it is verified there are no problems with current routing const OldRoutes = useMemo( () => diff --git a/airbyte-webapp/vite.config.ts b/airbyte-webapp/vite.config.ts index 64501710720e..6609883cb9f2 100644 --- a/airbyte-webapp/vite.config.ts +++ b/airbyte-webapp/vite.config.ts @@ -8,7 +8,7 @@ import checker from "vite-plugin-checker"; import svgrPlugin from "vite-plugin-svgr"; import viteTsconfigPaths from "vite-tsconfig-paths"; -import { docMiddleware } from "./packages/vite-plugins"; +import { buildInfo, docMiddleware } from "./packages/vite-plugins"; export default defineConfig(({ mode }) => { // Load variables from all .env files @@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => { plugins: [ basicSsl(), react(), + buildInfo(), viteTsconfigPaths(), svgrPlugin(), checker({