Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolves #3220 - show heartbeat status in top bar #3252

Merged
merged 21 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
}, []);
alexcottner marked this conversation as resolved.
Show resolved Hide resolved

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.restoreAllMocks();
vi.useRealTimers();
});

it.only("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
});

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);
act(async () => {
await vi.waitFor(() => {
expect(client.execute).toHaveBeenCalledTimes(2 + i);
});
});
}
});

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
Loading