Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.idea
**/.DS_Store
backend/ocpperf.toml
3 changes: 1 addition & 2 deletions backend/scripts/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
from pathlib import Path
import subprocess
import sys
from typing import Optional

import tomllib
from typing import Optional


def do(cmd: list[str]) -> list[str]:
Expand Down
406 changes: 82 additions & 324 deletions frontend/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions frontend/src/actions/headerActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as API_ROUTES from "@/utils/apiConstants";
import * as TYPES from "./types.js";

import API from "@/utils/axiosInstance";
import { showFailureToast } from "./toastActions.js";

export const setLastUpdatedTime = () => ({
type: TYPES.SET_LAST_UPDATED_TIME,
Expand All @@ -20,6 +19,7 @@ export const fetchAggregatorVersion = () => async (dispatch) => {
});
}
} catch (error) {
dispatch(showFailureToast());
// Error handling is done automatically by axios interceptor
console.error('Failed to fetch aggregator version:', error);
}
};
4 changes: 2 additions & 2 deletions frontend/src/actions/quayActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
import API from "@/utils/axiosInstance";
import { cloneDeep } from "lodash";
import { setLastUpdatedTime } from "./headerActions";
import { showFailureToast } from "@/actions/toastActions";
import { OTHERS } from "@/assets/constants/jobStatusConstants";

export const fetchQuayJobsData = () => async (dispatch) => {
Expand Down Expand Up @@ -256,7 +255,8 @@ export const fetchGraphData = (uuid) => async (dispatch, getState) => {
}
}
} catch (error) {
dispatch(showFailureToast());
// Error handling is done automatically by axios interceptor
console.error('Failed to fetch aggregator version:', error);
}
dispatch({ type: TYPES.GRAPH_COMPLETED });
};
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/components/organisms/ToastComponent/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { useDispatch, useSelector } from "react-redux";

import { hideToast } from "@/actions/toastActions";
import "./index.less";

const ToastComponent = () => {
const { alerts } = useSelector((state) => state.toast);
Expand All @@ -15,15 +16,32 @@ const ToastComponent = () => {
const removeToast = (key) => {
dispatch(hideToast(key));
};

// Different timeout durations based on alert type
const getTimeoutDuration = (variant) => {
switch (variant) {
case 'success':
return 2500; // Success messages can disappear quickly
case 'info':
return 3000; // Info messages - standard duration
case 'warning':
return 4000; // Warnings need a bit more time
case 'danger':
return 4500; // Error messages need more time to read
default:
return 3000; // Default fallback
}
};
return (
<AlertGroup isToast>
<AlertGroup isToast className="fast-fade-toast-group">
{alerts.map((item) => (
<Alert
variant={AlertVariant[item.variant]}
title={item.title}
key={item.key}
timeout={true}
timeout={getTimeoutDuration(item.variant)}
onTimeout={() => removeToast(item.key)}
className="fast-fade-toast"
actionClose={
<AlertActionCloseButton
title={item.title}
Expand Down
101 changes: 101 additions & 0 deletions frontend/src/components/organisms/ToastComponent/index.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Assisted-by: Cursor AI
// Toast animation styles for faster, smoother notifications

.fast-fade-toast-group {
// Ensure proper z-index for toasts
z-index: 9999;

// Animation for toast group
.pf-c-alert-group__item {
animation: slideInRight 0.3s ease-out;
transition: all 0.3s ease-out;
}
}

.fast-fade-toast {
// Faster transitions for all toast interactions
transition: all 0.3s ease-out !important;

// Smoother entrance animation
animation: slideInRight 0.3s ease-out;

// Override PatternFly's default fade-out animation to be faster
&.pf-m-fadeout {
animation: fadeOutRight 0.3s ease-in forwards !important;
opacity: 0;
transform: translateX(100%);
}

// Hover effects for better UX
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
}

// Keyframes for smooth animations
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}

@keyframes fadeOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}

// Additional improvements for toast appearance
.fast-fade-toast {
// Slightly more compact padding for quicker reading
.pf-c-alert__title {
font-weight: 600;
margin-bottom: 4px;
}

// Better spacing for message content
p {
margin: 2px 0;
line-height: 1.4;
}

// Smoother close button interaction
.pf-c-button.pf-m-plain {
transition: all 0.2s ease;

&:hover {
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
}
}

// Responsive behavior for mobile
@media (max-width: 768px) {
.fast-fade-toast-group {
// Ensure toasts are readable on mobile
.pf-c-alert-group__item {
margin: 0 8px;
}
}

.fast-fade-toast {
// Slightly smaller on mobile
font-size: 0.9rem;

.pf-c-alert__title {
font-size: 1rem;
}
}
}
161 changes: 161 additions & 0 deletions frontend/src/utils/__tests__/axiosInstance.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, it, expect, test } from "vitest";

import {
extractErrorMessage,
} from "../errorUtils";

// Test the error priority logic that would be used in axios interceptor
const mockErrorPrioritization = (axiosMessage, responseData, status = 500) => {
const responseMessage = extractErrorMessage(responseData, status);

// Use response message if available, otherwise fall back to axios message
const extractedMessage = responseMessage || axiosMessage;

if (extractedMessage) {
return {
message: extractedMessage,
source: responseMessage ? 'Response data extraction' : 'Axios error message'
};
}

return { message: null, source: 'none' };
};

describe("extractErrorMessage", () => {
// Status code based enhanced messages (any text gets enhanced for these status codes)
test.each([
["Internal Server Error", 500, "A backend service is temporarily unavailable."],
["Erreur interne du serveur", 500, "A backend service is temporarily unavailable."], // French
["Custom 500 error", 500, "A backend service is temporarily unavailable."],
["Bad Gateway", 502, "Unable to connect to backend service. Please try again in a moment."],
["Proxy Error", 502, "Unable to connect to backend service. Please try again in a moment."],
["Service Unavailable", 503, "The service is temporarily overloaded or under maintenance. Please try again later."],
["Temporarily Unavailable", 503, "The service is temporarily overloaded or under maintenance. Please try again later."],
["Gateway Timeout", 504, "The request timed out while connecting to external services. Please try again."],
["Request Timeout", 504, "The request timed out while connecting to external services. Please try again."]
])('enhances any plain text for status %d: "%s" -> enhanced message', (input, status, expected) => {
expect(extractErrorMessage(input, status)).toBe(expected);
});

// Non-enhanced status codes return original text
test.each([
["Custom server error", 400, "Custom server error"],
["Not Found", 404, "Not Found"],
["Unauthorized", 401, "Unauthorized"],
[" Whitespace trimmed ", 422, "Whitespace trimmed"]
])('returns original text for non-enhanced status %d: "%s"', (input, status, expected) => {
expect(extractErrorMessage(input, status)).toBe(expected);
});

// FastAPI error formats
test.each([
[{ detail: { message: "Custom error message" } }, 400, "Custom error message"],
[{ detail: { error: "Error object format" } }, 422, "Error object format"],
[{ detail: "String detail format" }, 400, "String detail format"],
[{ error: "Direct error key format" }, 400, "Direct error key format"],
[{ message: "Generic message field" }, 400, "Generic message field"]
])('extracts from various JSON formats', (input, status, expected) => {
expect(extractErrorMessage(input, status)).toBe(expected);
});

// Null/invalid inputs
test.each([
[null], [undefined], [{}], [123], [[]], [""], [" "], [{ detail: [] }]
])('returns enhanced message for status 500 even with unparseable data: %s', (input) => {
expect(extractErrorMessage(input, 500)).toBe("A backend service is temporarily unavailable.");
});

it("handles validation errors with field paths", () => {
const result = extractErrorMessage({
detail: [
{ msg: "Field required", loc: ["query", "start_date"] },
{ msg: "ensure this value is greater than 0", loc: ["query", "size"] },
],
}, 422);
expect(result).toContain("Multiple validation errors");
expect(result).toContain("Field required");
expect(result).toContain("greater than 0");
});

it("handles single validation error with nested path", () => {
const result = extractErrorMessage({
detail: [{ msg: "Field is required", loc: ["query", "start_date", "nested"] }],
}, 422);
expect(result).toBe("Field is required (start_date.nested)");
});
});


describe("Error Prioritization Logic", () => {
test.each([
["Request failed with status code 422", { detail: { message: "Database connection timeout" } }, "Database connection timeout", "Response data extraction"],
["Request failed with status code 500", "Internal Server Error", "A backend service is temporarily unavailable.", "Response data extraction"],
["Network Error", "Internal Server Error", "A backend service is temporarily unavailable.", "Response data extraction"],
["Connection refused to database server", "Internal Server Error", "A backend service is temporarily unavailable.", "Response data extraction"],
["timeout of 5000ms exceeded", "Internal Server Error", "A backend service is temporarily unavailable.", "Response data extraction"],
[null, { detail: { message: "Specific error" } }, "Specific error", "Response data extraction"],
[undefined, "Internal Server Error", "A backend service is temporarily unavailable.", "Response data extraction"],
["Network Error", { random: "data" }, "A backend service is temporarily unavailable.", "Response data extraction"],
["", "Internal Server Error", "A backend service is temporarily unavailable.", "Response data extraction"]
])('prioritization: axios="%s" response=%j -> message="%s" source="%s"', (axiosMessage, responseData, expectedMessage, expectedSource) => {
const result = mockErrorPrioritization(axiosMessage, responseData, responseData && typeof responseData === 'object' && responseData.detail ? 422 : 500);
expect(result.message).toBe(expectedMessage);
expect(result.source).toBe(expectedSource);
});

it("prefers validation errors over axios messages", () => {
const result = mockErrorPrioritization(
"Request failed with status code 422",
{ detail: [{ msg: "Field required", loc: ["query", "start_date"] }] },
422
);

expect(result).toEqual({
message: "Field required (start_date)",
source: "Response data extraction"
});
});
});

describe("Integration Tests - Full Error Handling Flow", () => {
const mockFullErrorHandling = (status, data, url, axiosMessage) => {
// Note: Current implementation shows toasts for ALL status codes (400-599)
const responseMessage = extractErrorMessage(data, status);

// Use response message if available, otherwise fall back to axios message
const extractedMessage = responseMessage || axiosMessage;
const messageSource = responseMessage ? 'Response data extraction' : 'Axios error message';

return {
showToast: true, // Now shows for all HTTP errors
message: extractedMessage,
source: messageSource
};
};

test.each([
[500, "Internal Server Error", "/api/v1/telco/filters", "Request failed with status code 500", "A backend service is temporarily unavailable.", "Response data extraction"],
[422, { detail: [{ msg: "Field required", loc: ["query", "start_date"] }] }, "/api/v1/ocp/jobs", "Request failed with status code 422", "Field required (start_date)", "Response data extraction"],
[400, { detail: { message: "Invalid date range" } }, "/api/v1/quay/jobs", "Request failed with status code 400", "Invalid date range", "Response data extraction"],
[404, { detail: "Not found" }, "/api/v1/ocp/jobs", "Request failed with status code 404", "Not found", "Response data extraction"]
])('status %d with %j -> message="%s" source="%s"', (status, data, url, axiosMessage, expectedMessage, expectedSource) => {
const result = mockFullErrorHandling(status, data, url, axiosMessage);

expect(result).toEqual({
showToast: true,
message: expectedMessage,
source: expectedSource
});
});

it("handles enhanced error messages", () => {
const result = mockFullErrorHandling(
504,
"Gateway Timeout",
"/api/v1/ols/jobs",
"timeout of 30000ms exceeded"
);

expect(result.message).toBe("The request timed out while connecting to external services. Please try again.");
});
});
Loading
Loading