Skip to content

Commit

Permalink
🪟 🎉 Show notification if Airbyte got updated (#22480)
Browse files Browse the repository at this point in the history
* 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 <josephkmh@users.noreply.github.com>

* Address review

* Address review

---------

Co-authored-by: Joey Marshment-Howell <josephkmh@users.noreply.github.com>
  • Loading branch information
timroes and josephkmh authored Feb 8, 2023
1 parent 8852910 commit ba41bba
Show file tree
Hide file tree
Showing 12 changed files with 113 additions and 10 deletions.
3 changes: 3 additions & 0 deletions airbyte-webapp/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions airbyte-webapp/packages/vite-plugins/build-info.ts
Original file line number Diff line number Diff line change
@@ -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),
},
}),
};
}
1 change: 1 addition & 0 deletions airbyte-webapp/packages/vite-plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { docMiddleware } from "./doc-middleware";
export { buildInfo } from "./build-info";
4 changes: 0 additions & 4 deletions airbyte-webapp/src/components/ui/Toast/Toast.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,6 @@ $toast-bottom-margin: 27px;
text-align: left;
}

.actionButton {
margin-top: variables.$spacing-xs;
}

.closeButton {
svg {
color: colors.$dark-blue-900;
Expand Down
14 changes: 11 additions & 3 deletions airbyte-webapp/src/components/ui/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ToastProps {
onAction?: () => void;
actionBtnText?: string;
onClose?: () => void;
"data-testid"?: string;
}

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

export const Toast: React.FC<ToastProps> = ({ type = ToastType.INFO, onAction, actionBtnText, onClose, text }) => {
export const Toast: React.FC<ToastProps> = ({
type = ToastType.INFO,
onAction,
actionBtnText,
onClose,
text,
"data-testid": testId,
}) => {
return (
<div className={classNames(styles.toastContainer, STYLES_BY_TYPE[type])}>
<div className={classNames(styles.toastContainer, STYLES_BY_TYPE[type])} data-testid={testId}>
<div className={classNames(styles.iconContainer)}>
<FontAwesomeIcon icon={ICON_MAPPING[type]} className={styles.toastIcon} />
</div>
Expand All @@ -52,7 +60,7 @@ export const Toast: React.FC<ToastProps> = ({ type = ToastType.INFO, onAction, a
)}
</div>
{onAction && (
<Button variant="dark" className={styles.actionButton} onClick={onAction}>
<Button variant="dark" onClick={onAction}>
{actionBtnText}
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const NotificationService = React.memo(({ children }: { children: React.R
<Toast
text={firstNotification.text}
type={firstNotification.type}
actionBtnText={firstNotification.actionBtnText}
onAction={firstNotification.onAction}
data-testid={`notification-${firstNotification.id}`}
onClose={
firstNotification.nonClosable
? undefined
Expand Down
4 changes: 2 additions & 2 deletions airbyte-webapp/src/hooks/services/Notification/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ToastProps } from "components/ui/Toast";

export interface Notification extends ToastProps {
id: string | number;
export interface Notification extends Pick<ToastProps, "type" | "onAction" | "onClose" | "actionBtnText" | "text"> {
id: string;
nonClosable?: boolean;
}

Expand Down
57 changes: 57 additions & 0 deletions airbyte-webapp/src/hooks/services/useBuildUpdateCheck.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
2 changes: 2 additions & 0 deletions airbyte-webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions airbyte-webapp/src/packages/cloud/cloudRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -117,6 +118,8 @@ export const Routing: React.FC = () => {

const { search } = useLocation();

useBuildUpdateCheck();

useEffectOnce(() => {
setSegmentAnonymousId(search);
});
Expand Down
3 changes: 3 additions & 0 deletions airbyte-webapp/src/pages/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
() =>
Expand Down
3 changes: 2 additions & 1 deletion airbyte-webapp/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +31,7 @@ export default defineConfig(({ mode }) => {
plugins: [
basicSsl(),
react(),
buildInfo(),
viteTsconfigPaths(),
svgrPlugin(),
checker({
Expand Down

0 comments on commit ba41bba

Please sign in to comment.