From a30b4d13d1f872e49f1415c88d238e14bafcb234 Mon Sep 17 00:00:00 2001 From: Alex Cottner <148472676+alexcottner@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:11:18 -0600 Subject: [PATCH] Resolves #3220 - show heartbeat status in top bar (#3252) * Showing heartbeat status in top bar. * Fixing login error that shows up during oauth redirect while here * Made heartbeat circle a clickable link --- src/actions/heartbeat.ts | 14 +++++ src/actions/session.ts | 24 +++++--- src/components/SessionInfoBar.tsx | 45 ++++++++++++-- src/constants.ts | 3 + src/reducers/heartbeat.ts | 21 +++++++ src/reducers/index.ts | 2 + src/sagas/heartbeat.ts | 39 ++++++++++++ src/sagas/index.ts | 3 + src/types.ts | 5 ++ test/components/SessionInfoBar_test.tsx | 80 ++++++++++++++++++++++++ test/sagas/heartbeat_test.ts | 81 +++++++++++++++++++++++++ 11 files changed, 302 insertions(+), 15 deletions(-) create mode 100644 src/actions/heartbeat.ts create mode 100644 src/reducers/heartbeat.ts create mode 100644 src/sagas/heartbeat.ts create mode 100644 test/components/SessionInfoBar_test.tsx create mode 100644 test/sagas/heartbeat_test.ts diff --git a/src/actions/heartbeat.ts b/src/actions/heartbeat.ts new file mode 100644 index 000000000..2db9a8a5b --- /dev/null +++ b/src/actions/heartbeat.ts @@ -0,0 +1,14 @@ +import { HEARTBEAT_REQUEST, HEARTBEAT_RESPONSE } from "@src/constants"; + +export function heartbeatRequest(): { + type: "HEARTBEAT_REQUEST"; +} { + return { type: HEARTBEAT_REQUEST }; +} + +export function heartbeatResponse(response: Record): { + type: "HEARTBEAT_RESPONSE"; + response: Record; +} { + return { type: HEARTBEAT_RESPONSE, response }; +} diff --git a/src/actions/session.ts b/src/actions/session.ts index 3b753d509..3187940cd 100644 --- a/src/actions/session.ts +++ b/src/actions/session.ts @@ -1,4 +1,4 @@ -import { notifyError } from "./notifications"; +import { notifyError, notifySuccess } from "./notifications"; import { SESSION_AUTHENTICATED, SESSION_AUTHENTICATION_FAILED, @@ -17,7 +17,7 @@ import { } from "@src/constants"; import type { ActionType, AuthData, ServerInfo } from "@src/types"; -type NavigationResult = ActionType | { type: null }; +const AUTH_REDIRECT_RESULT = notifySuccess("Redirecting to auth provider..."); export function sessionBusy(busy: boolean): { type: "SESSION_BUSY"; @@ -111,14 +111,17 @@ export function logout(): { return { type: SESSION_LOGOUT }; } -function navigateToFxA(server: string, redirect: string): NavigationResult { +function navigateToFxA(server: string, redirect: string) { document.location.href = `${server}/fxa-oauth/login?redirect=${encodeURIComponent( redirect )}`; - return { type: null }; + return AUTH_REDIRECT_RESULT; } -function postToPortier(server: string, redirect: string): NavigationResult { +function postToPortier( + server: string, + redirect: string +): ActionType { // Alter the AuthForm to make it posting Portier auth information to the // dedicated Kinto server endpoint. This is definitely one of the ugliest // part of this project, but it works :) @@ -144,7 +147,7 @@ function postToPortier(server: string, redirect: string): NavigationResult { hiddenRedirect.setAttribute("value", redirect); form.appendChild(hiddenRedirect); form.submit(); - return { type: null }; + return AUTH_REDIRECT_RESULT; } catch (error) { return notifyError("Couldn't redirect to authentication endpoint.", error); } @@ -153,7 +156,7 @@ function postToPortier(server: string, redirect: string): NavigationResult { export function navigateToOpenID( authFormData: any, provider: any -): NavigationResult { +): ActionType { const { origin, pathname } = document.location; const { server } = authFormData; const strippedServer = server.replace(/\/$/, ""); @@ -162,16 +165,19 @@ export function navigateToOpenID( const payload = btoa(JSON.stringify(authFormData)); const redirect = encodeURIComponent(`${origin}${pathname}#/auth/${payload}/`); document.location.href = `${strippedServer}/${strippedAuthPath}?callback=${redirect}&scope=openid email`; - return { type: null }; + return AUTH_REDIRECT_RESULT; } /** * Massive side effect: this will navigate away from the current page to perform * authentication to a third-party service, like FxA. */ -export function navigateToExternalAuth(authFormData: any): NavigationResult { +export function navigateToExternalAuth( + authFormData: any +): ActionType { const { origin, pathname } = document.location; const { server, authType } = authFormData; + try { const payload = btoa(JSON.stringify(authFormData)); const redirect = `${origin}${pathname}#/auth/${payload}/`; diff --git a/src/components/SessionInfoBar.tsx b/src/components/SessionInfoBar.tsx index abba59240..fde0d1c9a 100644 --- a/src/components/SessionInfoBar.tsx +++ b/src/components/SessionInfoBar.tsx @@ -1,15 +1,35 @@ +import * as HeartbeatActions from "@src/actions/heartbeat"; import * as SessionActions from "@src/actions/session"; import { useAppDispatch, useAppSelector } from "@src/hooks/app"; -import * as React from "react"; -import { BoxArrowRight } from "react-bootstrap-icons"; -import { QuestionCircleFill } from "react-bootstrap-icons"; -import { Clipboard } from "react-bootstrap-icons"; +import React, { useEffect } from "react"; +import { + BoxArrowRight, + CircleFill, + Clipboard, + ExclamationCircleFill, + QuestionCircleFill, +} from "react-bootstrap-icons"; export function SessionInfoBar() { - const { url, project_name, project_docs, user } = useAppSelector( - store => store.session.serverInfo + const { heartbeat, url, project_name, project_docs, user } = useAppSelector( + store => { + return { + ...store.session.serverInfo, + heartbeat: store.heartbeat, + }; + } ); const dispatch = useAppDispatch(); + + const checkHeartbeat = async () => { + dispatch(HeartbeatActions.heartbeatRequest()); + setTimeout(checkHeartbeat, 60000); + }; + + useEffect(() => { + checkHeartbeat(); + }, []); + return (

{project_name}

@@ -34,6 +54,19 @@ export function SessionInfoBar() { > + + {heartbeat.success !== false ? ( + + ) : ( + + )} + ) { bucket, collection, group, + heartbeat, record, notifications, servers, diff --git a/src/sagas/heartbeat.ts b/src/sagas/heartbeat.ts new file mode 100644 index 000000000..ab18be264 --- /dev/null +++ b/src/sagas/heartbeat.ts @@ -0,0 +1,39 @@ +import * as actions from "@src/actions/heartbeat"; +import { getClient } from "@src/client"; +import { ActionType, GetStateFn, SagaGen } from "@src/types"; +import { call, put } from "redux-saga/effects"; + +export function* heartbeatRequest( + getState: GetStateFn, + action: ActionType +): SagaGen { + const response = yield call(queryHeartbeat); + yield put(actions.heartbeatResponse(response)); +} + +async function queryHeartbeat(): Promise> { + const client = getClient(); + + try { + const response: Record = await client.execute({ + path: "/__heartbeat__", + headers: undefined, + }); + let success = true; + for (let prop in response) { + if (response[prop] === false) { + success = false; + break; + } + } + return { + success, + response, + }; + } catch (ex) { + return { + success: false, + details: ex, + }; + } +} diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 92c06ec5b..f6e82ed17 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -1,6 +1,7 @@ import * as bucketSagas from "./bucket"; import * as collectionSagas from "./collection"; import * as groupSagas from "./group"; +import * as heartbeatSagas from "./heartbeat"; import * as recordSagas from "./record"; import * as routeSagas from "./route"; import * as sessionSagas from "./session"; @@ -143,6 +144,8 @@ export default function* rootSaga(getState: GetStateFn): SagaGen { signoffSagas.handleApproveChanges, getState ), + // heartbeat + takeEvery(c.HEARTBEAT_REQUEST, heartbeatSagas.heartbeatRequest, getState), ]; yield all(sagas); diff --git a/src/types.ts b/src/types.ts index 34eff49a8..8f08c03a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -529,3 +529,8 @@ export type DestinationInfo = { bid: string; cid: string; }; + +export type HeartbeatState = { + success: boolean; + response: Record; +}; diff --git a/test/components/SessionInfoBar_test.tsx b/test/components/SessionInfoBar_test.tsx new file mode 100644 index 000000000..fe89e7a8f --- /dev/null +++ b/test/components/SessionInfoBar_test.tsx @@ -0,0 +1,80 @@ +import { setClient } from "@src/client"; +import { SessionInfoBar } from "@src/components/SessionInfoBar"; +import { renderWithProvider } from "@test/testUtils"; +import { act, screen, waitFor } from "@testing-library/react"; + +describe("SessionInfoBar component", () => { + const client = { + execute: vi.fn(), + }; + const healthyStr = "Server heartbeat status is healthy"; + const unhealthyStr = "Server heartbeat status IS NOT healthy"; + + beforeAll(() => { + setClient(client); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("Should show green server status by default and render user/server info as expected, and render again every minute", async () => { + vi.useFakeTimers(); + let fakeDate = new Date(2024, 0, 1); + vi.setSystemTime(fakeDate); + + client.execute.mockResolvedValue({}); + expect(client.execute).toHaveBeenCalledTimes(0); + renderWithProvider(); + await vi.waitFor(() => { + expect(client.execute).toHaveBeenCalledTimes(2); // 2 due to provider causing re-render in tests + }); + + expect(screen.getByTitle(healthyStr)).toBeDefined(); + expect(screen.getByTitle("Copy authentication header")).toBeDefined(); + expect(screen.getByText("Documentation")).toBeDefined(); + expect(screen.getByText("Logout")).toBeDefined(); + expect(screen.getByText("Anonymous")).toBeDefined(); + + // ensure execute is called every minute for 5 minutes + for (let i = 1; i < 5; i++) { + await vi.advanceTimersByTimeAsync(60100); + await act(async () => { + await vi.waitFor(() => { + expect(client.execute).toHaveBeenCalledTimes(2 + i * 2); + }); + }); + } + }); + + it("Should show green server status when heartbeat returns all true checks", async () => { + client.execute.mockResolvedValue({ + foo: true, + bar: true, + }); + renderWithProvider(); + await waitFor(() => new Promise(resolve => setTimeout(resolve, 100))); // debounce wait + expect(screen.getByTitle(healthyStr)).toBeDefined(); + }); + + it("Should show failed server status when heartbeat returns any false checks", async () => { + client.execute.mockResolvedValue({ + foo: false, + bar: true, + }); + renderWithProvider(); + await waitFor(() => new Promise(resolve => setTimeout(resolve, 100))); // debounce wait + expect(client.execute).toHaveBeenCalled(); + expect(screen.getByTitle(unhealthyStr)).toBeDefined(); + }); + + it("Should show failed server status when heartbeat check throws an error", async () => { + client.execute.mockImplementation(() => { + throw new Error("Test error"); + }); + renderWithProvider(); + await waitFor(() => new Promise(resolve => setTimeout(resolve, 100))); // debounce wait + expect(screen.getByTitle(unhealthyStr)).toBeDefined(); + }); +}); diff --git a/test/sagas/heartbeat_test.ts b/test/sagas/heartbeat_test.ts new file mode 100644 index 000000000..b0c60c7a4 --- /dev/null +++ b/test/sagas/heartbeat_test.ts @@ -0,0 +1,81 @@ +import * as actions from "@src/actions/heartbeat"; +import { setClient } from "@src/client"; +import { heartbeatRequest } from "@src/sagas/heartbeat"; +import { runSaga } from "redux-saga"; + +describe("Heartbeat saga", () => { + const getState = () => ({}); + const client = { + execute: vi.fn(), + }; + + beforeAll(() => { + setClient(client); + }); + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(actions, "heartbeatResponse"); + }); + + it("Should return the expected default model", async () => { + client.execute.mockResolvedValue({}); + let saga = runSaga( + { + dispatch: actions.heartbeatRequest, + getState, + }, + heartbeatRequest, + null + ); + expect(client.execute).toHaveBeenCalled(); + await saga.toPromise(); + expect(actions.heartbeatResponse).toHaveBeenCalledWith({ + success: true, + response: {}, + }); + }); + + it("Should return the expected state model", async () => { + client.execute.mockResolvedValue({ + foo: true, + bar: false, + }); + let saga = runSaga( + { + dispatch: actions.heartbeatRequest, + getState, + }, + heartbeatRequest, + null + ); + expect(client.execute).toHaveBeenCalled(); + await saga.toPromise(); + expect(actions.heartbeatResponse).toHaveBeenCalledWith({ + success: false, + response: { + foo: true, + bar: false, + }, + }); + }); + + it("Should return false for success when the client throws", async () => { + const err = new Error("throwing an error"); + client.execute.mockRejectedValue(err); + let saga = runSaga( + { + dispatch: actions.heartbeatRequest, + getState, + }, + heartbeatRequest, + null + ); + expect(client.execute).toHaveBeenCalled(); + await saga.toPromise(); + expect(actions.heartbeatResponse).toHaveBeenCalledWith({ + success: false, + details: err, + }); + }); +});