Skip to content

Commit

Permalink
Resolves #3220 - show heartbeat status in top bar (#3252)
Browse files Browse the repository at this point in the history
* Showing heartbeat status in top bar.
* Fixing login error that shows up during oauth redirect while here
* Made heartbeat circle a clickable link
  • Loading branch information
alexcottner authored Jun 7, 2024
1 parent 349a374 commit a30b4d1
Show file tree
Hide file tree
Showing 11 changed files with 302 additions and 15 deletions.
14 changes: 14 additions & 0 deletions src/actions/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>): {
type: "HEARTBEAT_RESPONSE";
response: Record<string, any>;
} {
return { type: HEARTBEAT_RESPONSE, response };
}
24 changes: 15 additions & 9 deletions src/actions/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { notifyError } from "./notifications";
import { notifyError, notifySuccess } from "./notifications";
import {
SESSION_AUTHENTICATED,
SESSION_AUTHENTICATION_FAILED,
Expand All @@ -17,7 +17,7 @@ import {
} from "@src/constants";
import type { ActionType, AuthData, ServerInfo } from "@src/types";

type NavigationResult = ActionType<typeof notifyError> | { type: null };
const AUTH_REDIRECT_RESULT = notifySuccess("Redirecting to auth provider...");

export function sessionBusy(busy: boolean): {
type: "SESSION_BUSY";
Expand Down Expand Up @@ -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<typeof notifyError> {
// 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 :)
Expand All @@ -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);
}
Expand All @@ -153,7 +156,7 @@ function postToPortier(server: string, redirect: string): NavigationResult {
export function navigateToOpenID(
authFormData: any,
provider: any
): NavigationResult {
): ActionType<typeof notifyError> {
const { origin, pathname } = document.location;
const { server } = authFormData;
const strippedServer = server.replace(/\/$/, "");
Expand All @@ -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<typeof notifyError> {
const { origin, pathname } = document.location;
const { server, authType } = authFormData;

try {
const payload = btoa(JSON.stringify(authFormData));
const redirect = `${origin}${pathname}#/auth/${payload}/`;
Expand Down
45 changes: 39 additions & 6 deletions src/components/SessionInfoBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="session-info-bar" data-testid="sessionInfo-bar">
<h1 className="kinto-admin-title">{project_name}</h1>
Expand All @@ -34,6 +54,19 @@ export function SessionInfoBar() {
>
<Clipboard className="icon" />
</a>
<a href={`${url}__heartbeat__`} target="_blank">
{heartbeat.success !== false ? (
<CircleFill
color="green"
title="Server heartbeat status is healthy"
/>
) : (
<ExclamationCircleFill
color="red"
title="Server heartbeat status IS NOT healthy"
/>
)}
</a>
<a
href={project_docs}
target="_blank"
Expand Down
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export const GROUP_HISTORY_REQUEST = "GROUP_HISTORY_REQUEST";
export const GROUP_HISTORY_NEXT_REQUEST = "GROUP_HISTORY_NEXT_REQUEST";
export const GROUP_HISTORY_SUCCESS = "GROUP_HISTORY_SUCCESS";

export const HEARTBEAT_REQUEST = "HEARTBEAT_REQUEST";
export const HEARTBEAT_RESPONSE = "HEARTBEAT_RESPONSE";

export const NOTIFICATION_ADDED = "NOTIFICATION_ADDED";
export const NOTIFICATION_REMOVED = "NOTIFICATION_REMOVED";
export const NOTIFICATION_CLEAR = "NOTIFICATION_CLEAR";
Expand Down
21 changes: 21 additions & 0 deletions src/reducers/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { HEARTBEAT_RESPONSE } from "@src/constants";
import { HeartbeatState } from "@src/types";

const INITIAL_STATE: HeartbeatState = {
success: true,
response: {},
};

export default function servers(
state: HeartbeatState = INITIAL_STATE,
action: any
): HeartbeatState {
switch (action.type) {
case HEARTBEAT_RESPONSE: {
return action.response;
}
default: {
return state;
}
}
}
2 changes: 2 additions & 0 deletions src/reducers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import bucket from "./bucket";
import collection from "./collection";
import group from "./group";
import heartbeat from "./heartbeat";
import notifications from "./notifications";
import record from "./record";
import servers from "./servers";
Expand All @@ -17,6 +18,7 @@ export default function createRootReducer(routerReducer: Reducer<RouterState>) {
bucket,
collection,
group,
heartbeat,
record,
notifications,
servers,
Expand Down
39 changes: 39 additions & 0 deletions src/sagas/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -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<typeof actions.heartbeatRequest>
): SagaGen {
const response = yield call(queryHeartbeat);
yield put(actions.heartbeatResponse(response));
}

async function queryHeartbeat(): Promise<Record<string, any>> {
const client = getClient();

try {
const response: Record<string, any> = 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,
};
}
}
3 changes: 3 additions & 0 deletions src/sagas/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,8 @@ export type DestinationInfo = {
bid: string;
cid: string;
};

export type HeartbeatState = {
success: boolean;
response: Record<string, any>;
};
80 changes: 80 additions & 0 deletions test/components/SessionInfoBar_test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SessionInfoBar />);
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(<SessionInfoBar />);
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(<SessionInfoBar />);
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(<SessionInfoBar />);
await waitFor(() => new Promise(resolve => setTimeout(resolve, 100))); // debounce wait
expect(screen.getByTitle(unhealthyStr)).toBeDefined();
});
});
Loading

0 comments on commit a30b4d1

Please sign in to comment.