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 8 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
30 changes: 22 additions & 8 deletions src/actions/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,15 @@ import {
} from "@src/constants";
import type { ActionType, AuthData, ServerInfo } from "@src/types";

type NavigationResult = ActionType<typeof notifyError> | { type: null };
const AUTH_REDIRECT_RESULT: ActionType<typeof notifyError> = {
notification: {
alexcottner marked this conversation as resolved.
Show resolved Hide resolved
details: [],
message: "Redirecting to auth provider...",
timeout: 0,
type: "success",
},
type: "NOTIFICATION_ADDED",
grahamalama marked this conversation as resolved.
Show resolved Hide resolved
};

export function sessionBusy(busy: boolean): {
type: "SESSION_BUSY";
Expand Down Expand Up @@ -111,14 +119,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 +155,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 +164,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 +173,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
46 changes: 44 additions & 2 deletions src/components/SessionInfoBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as SessionActions from "@src/actions/session";
import { getClient } from "@src/client";
import { useAppDispatch, useAppSelector } from "@src/hooks/app";
import * as React from "react";
import { BoxArrowRight } from "react-bootstrap-icons";
import { KintoResponse } from "kinto/lib/types";
import React, { useEffect, useState } from "react";
grahamalama marked this conversation as resolved.
Show resolved Hide resolved
import { BoxArrowRight, CircleFill, XCircleFill } from "react-bootstrap-icons";
import { QuestionCircleFill } from "react-bootstrap-icons";
import { Clipboard } from "react-bootstrap-icons";

Expand All @@ -10,6 +12,33 @@ export function SessionInfoBar() {
store => store.session.serverInfo
);
const dispatch = useAppDispatch();
const [isHealthy, setIsHealthy] = useState(true);
const client = getClient();

const checkHeartbeat = async () => {
grahamalama marked this conversation as resolved.
Show resolved Hide resolved
try {
let res: KintoResponse = await client.execute({
alexcottner marked this conversation as resolved.
Show resolved Hide resolved
path: "/__heartbeat__",
headers: undefined,
});
for (let p in res) {
if (!res[p]) {
alexcottner marked this conversation as resolved.
Show resolved Hide resolved
setIsHealthy(false);
return;
}
}
setIsHealthy(true);
} catch (ex) {
setIsHealthy(false);
} finally {
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 +63,19 @@ export function SessionInfoBar() {
>
<Clipboard className="icon" />
</a>
<a href={`${url}__heartbeat__`} target="_blank">
{isHealthy ? (
<CircleFill
color="green"
title="Server heartbeat status is healthy"
/>
) : (
<XCircleFill
color="red"
title="Server heartbeat status IS NOT healthy"
/>
)}
</a>
<a
href={project_docs}
target="_blank"
Expand Down
62 changes: 62 additions & 0 deletions test/components/SessionInfoBar_test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { setClient } from "@src/client";
import { SessionInfoBar } from "@src/components/SessionInfoBar";
import { renderWithProvider } from "@test/testUtils";
import { 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);
});

beforeEach(() => {
vi.resetAllMocks();
});

it("Should show green server status by default and render user/server info as expected", async () => {
client.execute.mockResolvedValue({});
renderWithProvider(<SessionInfoBar />);
await waitFor(() => new Promise(resolve => setTimeout(resolve, 100))); // debounce wait
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();
});

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();
});
});