Skip to content

Commit cab9a4f

Browse files
Tim RoesJoey Marshment-Howell
authored andcommitted
🪟 🎉 Show notification if Airbyte got updated (airbytehq#22480)
* WIP * Pass through data-testid * Also check on window.focus * Add more documentation * Update airbyte-webapp/src/hooks/services/useBuildUpdateCheck.ts Co-authored-by: Joey Marshment-Howell <[email protected]> * Address review * Address review --------- Co-authored-by: Joey Marshment-Howell <[email protected]>
1 parent f1fd559 commit cab9a4f

File tree

12 files changed

+113
-10
lines changed

12 files changed

+113
-10
lines changed

airbyte-webapp/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ yarn-error.log*
3333

3434
storybook-static/
3535

36+
# Generated by our build-info plugin
37+
/public/buildInfo.json
38+
3639
# Ignore generated API clients, since they're automatically generated
3740
/src/core/request/AirbyteClient.ts
3841
/src/core/request/ConnectorBuilderClient.ts
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Plugin } from "vite";
2+
3+
import fs from "fs";
4+
import path from "path";
5+
6+
import { v4 as uuidV4 } from "uuid";
7+
8+
const buildHash = uuidV4();
9+
10+
/**
11+
* A Vite plugin that will generate on every build a new random UUID and write that to the `/buildInfo.json`
12+
* file as well as make it available as `process.env.BUILD_HASH` in code.
13+
*/
14+
export function buildInfo(): Plugin {
15+
return {
16+
name: "airbyte/build-info",
17+
buildStart() {
18+
fs.writeFileSync(path.resolve(__dirname, "../../public/buildInfo.json"), JSON.stringify({ build: buildHash }));
19+
},
20+
config: () => ({
21+
define: {
22+
"process.env.BUILD_HASH": JSON.stringify(buildHash),
23+
},
24+
}),
25+
};
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { docMiddleware } from "./doc-middleware";
2+
export { buildInfo } from "./build-info";

airbyte-webapp/src/components/ui/Toast/Toast.module.scss

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,6 @@ $toast-bottom-margin: 27px;
8888
text-align: left;
8989
}
9090

91-
.actionButton {
92-
margin-top: variables.$spacing-xs;
93-
}
94-
9591
.closeButton {
9692
svg {
9793
color: colors.$dark-blue-900;

airbyte-webapp/src/components/ui/Toast/Toast.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface ToastProps {
2222
onAction?: () => void;
2323
actionBtnText?: string;
2424
onClose?: () => void;
25+
"data-testid"?: string;
2526
}
2627

2728
const ICON_MAPPING = {
@@ -38,9 +39,16 @@ const STYLES_BY_TYPE: Readonly<Record<ToastType, string>> = {
3839
[ToastType.INFO]: styles.info,
3940
};
4041

41-
export const Toast: React.FC<ToastProps> = ({ type = ToastType.INFO, onAction, actionBtnText, onClose, text }) => {
42+
export const Toast: React.FC<ToastProps> = ({
43+
type = ToastType.INFO,
44+
onAction,
45+
actionBtnText,
46+
onClose,
47+
text,
48+
"data-testid": testId,
49+
}) => {
4250
return (
43-
<div className={classNames(styles.toastContainer, STYLES_BY_TYPE[type])}>
51+
<div className={classNames(styles.toastContainer, STYLES_BY_TYPE[type])} data-testid={testId}>
4452
<div className={classNames(styles.iconContainer)}>
4553
<FontAwesomeIcon icon={ICON_MAPPING[type]} className={styles.toastIcon} />
4654
</div>
@@ -52,7 +60,7 @@ export const Toast: React.FC<ToastProps> = ({ type = ToastType.INFO, onAction, a
5260
)}
5361
</div>
5462
{onAction && (
55-
<Button variant="dark" className={styles.actionButton} onClick={onAction}>
63+
<Button variant="dark" onClick={onAction}>
5664
{actionBtnText}
5765
</Button>
5866
)}

airbyte-webapp/src/hooks/services/Notification/NotificationService.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ export const NotificationService = React.memo(({ children }: { children: React.R
3535
<Toast
3636
text={firstNotification.text}
3737
type={firstNotification.type}
38+
actionBtnText={firstNotification.actionBtnText}
39+
onAction={firstNotification.onAction}
40+
data-testid={`notification-${firstNotification.id}`}
3841
onClose={
3942
firstNotification.nonClosable
4043
? undefined

airbyte-webapp/src/hooks/services/Notification/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ToastProps } from "components/ui/Toast";
22

3-
export interface Notification extends ToastProps {
4-
id: string | number;
3+
export interface Notification extends Pick<ToastProps, "type" | "onAction" | "onClose" | "actionBtnText" | "text"> {
4+
id: string;
55
nonClosable?: boolean;
66
}
77

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEffect } from "react";
2+
import { useIntl } from "react-intl";
3+
import { fromEvent, interval, merge, throttleTime } from "rxjs";
4+
5+
import { useAppMonitoringService } from "./AppMonitoringService";
6+
import { useNotificationService } from "./Notification";
7+
8+
interface BuildInfo {
9+
build: string;
10+
}
11+
12+
const INTERVAL = 60_000;
13+
const MINIMUM_WAIT_BEFORE_REFETCH = 10_000;
14+
15+
/**
16+
* This hook sets up a check to /buildInfo.json, which is generated by the build system on every build
17+
* with a new hash. If it ever detects a new hash in it, we know that the Airbyte instance got updated
18+
* and show a notification to the user to reload the page.
19+
*/
20+
export const useBuildUpdateCheck = () => {
21+
const { formatMessage } = useIntl();
22+
const { registerNotification } = useNotificationService();
23+
const { trackError } = useAppMonitoringService();
24+
25+
useEffect(() => {
26+
// Trigger the check every ${INTERVAL} milliseconds or whenever the window regains focus again
27+
const subscription = merge(interval(INTERVAL), fromEvent(window, "focus"))
28+
// Throttle it to maximum once every 10 seconds
29+
.pipe(throttleTime(MINIMUM_WAIT_BEFORE_REFETCH))
30+
.subscribe(async () => {
31+
try {
32+
// Fetch the buildInfo.json file without using any browser cache
33+
const buildInfo: BuildInfo = await fetch("/buildInfo.json", { cache: "no-store" }).then((resp) =>
34+
resp.json()
35+
);
36+
37+
if (buildInfo.build !== process.env.BUILD_HASH) {
38+
registerNotification({
39+
id: "webapp-updated",
40+
text: formatMessage({ id: "notifications.airbyteUpdated" }),
41+
nonClosable: true,
42+
actionBtnText: formatMessage({ id: "notifications.airbyteUpdated.reload" }),
43+
onAction: () => window.location.reload(),
44+
});
45+
}
46+
} catch (e) {
47+
// We ignore all errors from the build update check, since it's an optional check to
48+
// inform the user. We don't want to treat failed requests here as errors.
49+
trackError(e);
50+
}
51+
});
52+
53+
return () => {
54+
subscription.unsubscribe();
55+
};
56+
}, [formatMessage, registerNotification, trackError]);
57+
};

airbyte-webapp/src/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"notifications.error.health": "Cannot reach server",
44
"notifications.error.somethingWentWrong": "Something went wrong",
55
"notifications.error.noMessage": "No error message",
6+
"notifications.airbyteUpdated": "Airbyte has been updated. Please reload the page.",
7+
"notifications.airbyteUpdated.reload": "Reload now",
68

79
"sidebar.homepage": "Homepage",
810
"sidebar.sources": "Sources",

airbyte-webapp/src/packages/cloud/cloudRoutes.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import LoadingPage from "components/LoadingPage";
88
import { useAnalyticsIdentifyUser, useAnalyticsRegisterValues } from "hooks/services/Analytics/useAnalyticsService";
99
import { FeatureItem, FeatureSet, useFeatureService } from "hooks/services/Feature";
1010
import { useApiHealthPoll } from "hooks/services/Health";
11+
import { useBuildUpdateCheck } from "hooks/services/useBuildUpdateCheck";
1112
import { useQuery } from "hooks/useQuery";
1213
import { useAuthService } from "packages/cloud/services/auth/AuthService";
1314
import { useCurrentWorkspace, WorkspaceServiceProvider } from "services/workspaces/WorkspacesService";
@@ -117,6 +118,8 @@ export const Routing: React.FC = () => {
117118

118119
const { search } = useLocation();
119120

121+
useBuildUpdateCheck();
122+
120123
useEffectOnce(() => {
121124
setSegmentAnonymousId(search);
122125
});

airbyte-webapp/src/pages/routes.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ApiErrorBoundary } from "components/common/ApiErrorBoundary";
55

66
import { useAnalyticsIdentifyUser, useAnalyticsRegisterValues } from "hooks/services/Analytics";
77
import { useApiHealthPoll } from "hooks/services/Health";
8+
import { useBuildUpdateCheck } from "hooks/services/useBuildUpdateCheck";
89
import { useCurrentWorkspace } from "hooks/services/useWorkspace";
910
import { useListWorkspaces } from "services/workspaces/WorkspacesService";
1011
import { CompleteOauthRequest } from "views/CompleteOauthRequest";
@@ -92,6 +93,8 @@ const RoutingWithWorkspace: React.FC<{ element?: JSX.Element }> = ({ element })
9293
};
9394

9495
export const Routing: React.FC = () => {
96+
useBuildUpdateCheck();
97+
9598
// TODO: Remove this after it is verified there are no problems with current routing
9699
const OldRoutes = useMemo(
97100
() =>

airbyte-webapp/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import checker from "vite-plugin-checker";
88
import svgrPlugin from "vite-plugin-svgr";
99
import viteTsconfigPaths from "vite-tsconfig-paths";
1010

11-
import { docMiddleware } from "./packages/vite-plugins";
11+
import { buildInfo, docMiddleware } from "./packages/vite-plugins";
1212

1313
export default defineConfig(({ mode }) => {
1414
// Load variables from all .env files
@@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => {
3131
plugins: [
3232
basicSsl(),
3333
react(),
34+
buildInfo(),
3435
viteTsconfigPaths(),
3536
svgrPlugin(),
3637
checker({

0 commit comments

Comments
 (0)