Compare commits
252
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1244ff6d93 | ||
|
|
53f420cf56 | ||
|
|
589825c1c5 | ||
|
|
6efc05879a | ||
|
|
c0ea804c98 | ||
|
|
20dccb140e | ||
|
|
8dfedb8a52 | ||
|
|
c21cf29db6 | ||
|
|
d48cdaca9c | ||
|
|
36a53ee80b | ||
|
|
3d0da70ce6 | ||
|
|
4400ca703d | ||
|
|
c1d0cb568d | ||
|
|
81e73e5adf | ||
|
|
7a84d06a79 | ||
|
|
63d2bb97ee | ||
|
|
0af7cfe714 | ||
|
|
d190f5ab02 | ||
|
|
8cfe1d3c1a | ||
|
|
1bf4c75418 | ||
|
|
7771a0ce35 | ||
|
|
fcd9e78367 | ||
|
|
6f9fefddd2 | ||
|
|
5731283c46 | ||
|
|
420ff292d9 | ||
|
|
290ee7d2df | ||
|
|
50c644231e | ||
|
|
5729b0a263 | ||
|
|
4f3f5408ef | ||
|
|
de7038d6c3 | ||
|
|
4119189c71 | ||
|
|
fd66affea8 | ||
|
|
839dc201df | ||
|
|
bbb0716251 | ||
|
|
bbdfc00eb9 | ||
|
|
ba498534e6 | ||
|
|
f51bf36ddd | ||
|
|
716479c5bb | ||
|
|
f9c85e2d76 | ||
|
|
e26f3facf1 | ||
|
|
fd5f493345 | ||
|
|
e5bb736530 | ||
|
|
d71d16ecd0 | ||
|
|
389716fa77 | ||
|
|
1bcb2ddf91 | ||
|
|
b61077bd3f | ||
|
|
1cffd51bd3 | ||
|
|
451e352458 | ||
|
|
a07e41d8f1 | ||
|
|
90e9b41973 | ||
|
|
1819df5117 | ||
|
|
6380dcc6ba | ||
|
|
6cb45ea6de | ||
|
|
67f74dd7de | ||
|
|
8f58ceafab | ||
|
|
c7aa45cd77 | ||
|
|
d0727f91dd | ||
|
|
967b2b200b | ||
|
|
d71c7c6ee1 | ||
|
|
6daa79f0ed | ||
|
|
93a5eff562 | ||
|
|
11c95f885d | ||
|
|
174665e70a | ||
|
|
64de01437e | ||
|
|
a64885e609 | ||
|
|
e354fa7ebf | ||
|
|
3208cc9e2c | ||
|
|
79dfa886f1 | ||
|
|
8af8efbe3b | ||
|
|
71bd1fa28f | ||
|
|
a2569c50c7 | ||
|
|
f7ae11997d | ||
|
|
0b7a41dc78 | ||
|
|
d42d5f2325 | ||
|
|
369d16c7c3 | ||
|
|
6e8172c126 | ||
|
|
794018a6ab | ||
|
|
8d6bc7bc0b | ||
|
|
30631d6ff8 | ||
|
|
56c5ad419a | ||
|
|
b852293447 | ||
|
|
d877ecf061 | ||
|
|
73e90bfab6 | ||
|
|
a55163de56 | ||
|
|
f5c6abad1b | ||
|
|
3dbbe035a8 | ||
|
|
9904626526 | ||
|
|
61f3aa1d1c | ||
|
|
a0a8ceb0db | ||
|
|
c2dece862c | ||
|
|
69018a4944 | ||
|
|
363d89e4f4 | ||
|
|
f8bb01190e | ||
|
|
00cf0b6521 | ||
|
|
a11d551a99 | ||
|
|
b35be1ecfc | ||
|
|
9d64de57b3 | ||
|
|
de6c4d9c3e | ||
|
|
e0e79fb0da | ||
|
|
3339b6b67f | ||
|
|
b511cde849 | ||
|
|
ce322a8e28 | ||
|
|
d25ca473de | ||
|
|
5292ca8988 | ||
|
|
c98cba2eef | ||
|
|
6b89db131d | ||
|
|
995ae5252d | ||
|
|
e6c268f14e | ||
|
|
5aef405414 | ||
|
|
a5112a7955 | ||
|
|
5404b65ce2 | ||
|
|
34dfe33c29 | ||
|
|
1e24c8841e | ||
|
|
0c04c06781 | ||
|
|
7441169a0e | ||
|
|
3520e5adf4 | ||
|
|
227e0128f9 | ||
|
|
2359255ef7 | ||
|
|
b074f2401f | ||
|
|
eee3caf021 | ||
|
|
f9848e4e1e | ||
|
|
8bd608bee0 | ||
|
|
ea589d6be4 | ||
|
|
5a0078cf92 | ||
|
|
f4a5bd65ad | ||
|
|
4766594413 | ||
|
|
9962866ba5 | ||
|
|
82cc289f53 | ||
|
|
117c22fae4 | ||
|
|
e0cabdb4fc | ||
|
|
155a2577d0 | ||
|
|
d627a25b40 | ||
|
|
b8a5af9c10 | ||
|
|
5b43d9d173 | ||
|
|
801b716a03 | ||
|
|
fdc3983328 | ||
|
|
0cf85f8b3b | ||
|
|
0ea4a5024a | ||
|
|
09513696ee | ||
|
|
b0da1f4259 | ||
|
|
5b96075c03 | ||
|
|
36e02e0a9a | ||
|
|
9fa0113f2b | ||
|
|
371e8cc154 | ||
|
|
7b4286646b | ||
|
|
2a8d2cb06a | ||
|
|
147eff3ede | ||
|
|
81c7c992e2 | ||
|
|
35488fed3e | ||
|
|
760abe69ed | ||
|
|
d242382abf | ||
|
|
ef61a63da0 | ||
|
|
3fa370765e | ||
|
|
3b5e793f8f | ||
|
|
b34dbec3cf | ||
|
|
90eee474d2 | ||
|
|
63416ceb4b | ||
|
|
eb8c9bea76 | ||
|
|
63b4779961 | ||
|
|
d2040495d6 | ||
|
|
f436e15569 | ||
|
|
952c70fca4 | ||
|
|
4957209307 | ||
|
|
cc9850c975 | ||
|
|
c6ac48b4ff | ||
|
|
82584d85c7 | ||
|
|
ffd67150eb | ||
|
|
44079f2eb8 | ||
|
|
f03ded4109 | ||
|
|
fa520e5f88 | ||
|
|
e9fbb472f4 | ||
|
|
de236f03eb | ||
|
|
4f043fe1d4 | ||
|
|
102bd0a5f8 | ||
|
|
118372630e | ||
|
|
27dea6a08e | ||
|
|
dd259fea89 | ||
|
|
9af25a19e5 | ||
|
|
6101fe1bfa | ||
|
|
795146c79b | ||
|
|
a69091bb28 | ||
|
|
6160806963 | ||
|
|
c1b2bda623 | ||
|
|
c0e4893f96 | ||
|
|
476ff39a17 | ||
|
|
f467ef1411 | ||
|
|
36b02e5e66 | ||
|
|
f6fd8729c3 | ||
|
|
cdfc87da09 | ||
|
|
427e5a18ea | ||
|
|
f696b4ef9b | ||
|
|
69d050ff61 | ||
|
|
7afdcb8ebc | ||
|
|
3cf7243c93 | ||
|
|
4ed0033a41 | ||
|
|
bafd82a691 | ||
|
|
adb2a383cf | ||
|
|
e91a54b75c | ||
|
|
33dfedb4d3 | ||
|
|
dde54a4e45 | ||
|
|
5f17520ba0 | ||
|
|
bebb5d6942 | ||
|
|
7c9410f9d7 | ||
|
|
348ca0dd02 | ||
|
|
1d36f40caf | ||
|
|
9006707e57 | ||
|
|
be82a7ee2d | ||
|
|
fd33b903ed | ||
|
|
ab25531758 | ||
|
|
f7f764fd68 | ||
|
|
3b0326457f | ||
|
|
7d6bc4b2dc | ||
|
|
c2487f0134 | ||
|
|
a86588f080 | ||
|
|
4e9839e2e5 | ||
|
|
487f6b222d | ||
|
|
391f42ee53 | ||
|
|
cff5508614 | ||
|
|
a0ebf7d224 | ||
|
|
ec9c79fc94 | ||
|
|
3e8d4cb49b | ||
|
|
288ac08cd6 | ||
|
|
ba35230919 | ||
|
|
048e53ea15 | ||
|
|
e498bd60b1 | ||
|
|
a4cf53ee15 | ||
|
|
3a77c6d7fa | ||
|
|
b47f3710fe | ||
|
|
9c92542653 | ||
|
|
173a4e17d2 | ||
|
|
70b197675e | ||
|
|
11dcccc085 | ||
|
|
e3e08da259 | ||
|
|
6447c315ce | ||
|
|
cb5b8e0673 | ||
|
|
88bb8ef2d3 | ||
|
|
64dc381e0f | ||
|
|
af82dec2ad | ||
|
|
7436c2b5df | ||
|
|
2055943876 | ||
|
|
d91e87f484 | ||
|
|
bf15018173 | ||
|
|
86a2171988 | ||
|
|
1c01dcaf31 | ||
|
|
feb061240b | ||
|
|
a924227805 | ||
|
|
2144d3f8c0 | ||
|
|
fcd6786149 | ||
|
|
ea968ebf3c | ||
|
|
8a7b852974 | ||
|
|
c8d3e3b70c | ||
|
|
7a90a6f9f4 |
No files matched your search
Generated
+2
-2
@@ -26,7 +26,7 @@
|
||||
"joi": "17.13.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mui-color-input": "^6.0.0",
|
||||
"pretty-ms": "9.3.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "7.63.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"react-toastify": "^10.0.5",
|
||||
"recharts": "2.15.2",
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "2.3.6",
|
||||
"swr": "^2.3.6",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"zod": "4.1.11"
|
||||
},
|
||||
|
||||
+2
-2
@@ -31,7 +31,7 @@
|
||||
"joi": "17.13.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mui-color-input": "^6.0.0",
|
||||
"pretty-ms": "9.3.0",
|
||||
"pretty-ms": "^9.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "7.63.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"react-toastify": "^10.0.5",
|
||||
"recharts": "2.15.2",
|
||||
"redux-persist": "6.0.0",
|
||||
"swr": "2.3.6",
|
||||
"swr": "^2.3.6",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"zod": "4.1.11"
|
||||
},
|
||||
|
||||
+18
-2
@@ -22,13 +22,29 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const theme = mode === "light" ? lightTheme : darkTheme;
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
body: {
|
||||
backgroundColor: theme.palette.background.main,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<AppLayout>
|
||||
<Routes />
|
||||
</AppLayout>
|
||||
<ToastContainer />
|
||||
<ToastContainer
|
||||
newestOnTop={true}
|
||||
theme={mode}
|
||||
style={{
|
||||
"--toastify-color-progress-light": "#7C8BA1",
|
||||
"--toastify-color-progress-dark": "#7C8BA1",
|
||||
}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ const ActionsMenu = ({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let monitor = { id: actions.id };
|
||||
console.log(monitor);
|
||||
await deleteMonitor({ monitor });
|
||||
updateRowCallback();
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import "./index.css";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Stack, Box, Tooltip, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
const PlaceholderCheck = ({ daysToShow }) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import {
|
||||
tooltipDateFormatLookup,
|
||||
tickDateFormatLookup,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Box, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import { useEffect, useState, forwardRef } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
@@ -148,7 +148,7 @@ const StatusPageBarChart = ({ checks = [] }) => {
|
||||
</>
|
||||
}
|
||||
placement="top"
|
||||
key={`check-${check?._id}`}
|
||||
key={`check-${check?.id}`}
|
||||
slotProps={{
|
||||
popper: {
|
||||
className: "bar-tooltip",
|
||||
|
||||
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { Text } from "recharts";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js";
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,14 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) =>
|
||||
fontWeight: 450,
|
||||
}}
|
||||
>
|
||||
{noHighlightText && <Typography component="span">{noHighlightText}</Typography>}{" "}
|
||||
{noHighlightText && (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: "inherit" }}
|
||||
>
|
||||
{noHighlightText}
|
||||
</Typography>
|
||||
)}{" "}
|
||||
{text}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -82,6 +83,7 @@ const iconComponents = {
|
||||
Calendar,
|
||||
Check,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
|
||||
@@ -14,15 +14,7 @@
|
||||
}
|
||||
} */
|
||||
|
||||
/* .home-layout aside {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
max-width: var(--env-var-side-bar-width);
|
||||
} */
|
||||
|
||||
.home-layout > div {
|
||||
.home-layout > .home-content-wrapper {
|
||||
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import Sidebar from "../../Sidebar/index.jsx";
|
||||
import { Outlet } from "react-router";
|
||||
import { Stack } from "@mui/material";
|
||||
import { Box, Stack } from "@mui/material";
|
||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const HomeLayout = () => {
|
||||
const { width, transition } = useSidebar();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="home-layout"
|
||||
flexDirection="row"
|
||||
gap={14}
|
||||
>
|
||||
<Sidebar />
|
||||
{/* Spacer for fixed sidebar */}
|
||||
<Box
|
||||
sx={{
|
||||
width,
|
||||
flexShrink: 0,
|
||||
transition,
|
||||
}}
|
||||
/>
|
||||
<Stack className="home-content-wrapper">
|
||||
<Outlet />
|
||||
</Stack>
|
||||
|
||||
@@ -124,7 +124,7 @@ const MonitorDetailsControlHeader = ({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{monitor?.isActive ? "Pause" : "Resume"}
|
||||
{monitor?.isActive ? t("pause") : t("resume")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
|
||||
@@ -4,7 +4,7 @@ import PulseDot from "../Animated/PulseDot.jsx";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Dot from "../Dot/index.jsx";
|
||||
// Utils
|
||||
import { formatDurationRounded } from "../../../Utils/timeUtils.js";
|
||||
import { formatDurationRounded } from "../../../Utils/timeUtilsLegacy.js";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js";
|
||||
|
||||
@@ -31,7 +31,7 @@ const NotificationConfig = ({
|
||||
setMonitor((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
notifications: value.map((notification) => notification._id),
|
||||
notifications: value.map((notification) => notification.id),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -39,14 +39,14 @@ const NotificationConfig = ({
|
||||
// Handlers
|
||||
const handleDelete = (id) => {
|
||||
const updatedNotifications = selectedNotifications.filter(
|
||||
(notification) => notification._id !== id
|
||||
(notification) => notification.id !== id
|
||||
);
|
||||
|
||||
setSelectedNotifications(updatedNotifications);
|
||||
setMonitor((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
notifications: updatedNotifications.map((notification) => notification._id),
|
||||
notifications: updatedNotifications.map((notification) => notification.id),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -57,7 +57,7 @@ const NotificationConfig = ({
|
||||
useEffect(() => {
|
||||
if (setNotifications) {
|
||||
const toSet = setNotifications.map((notification) => {
|
||||
return notifications.find((n) => n._id === notification);
|
||||
return notifications.find((n) => n.id === notification);
|
||||
});
|
||||
setSelectedNotifications(toSet);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ const NotificationConfig = ({
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
key={notification._id}
|
||||
key={notification.id}
|
||||
width="100%"
|
||||
>
|
||||
<Typography
|
||||
@@ -106,7 +106,7 @@ const NotificationConfig = ({
|
||||
name="Trash2"
|
||||
size={20}
|
||||
onClick={() => {
|
||||
handleDelete(notification._id);
|
||||
handleDelete(notification.id);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
|
||||
@@ -12,9 +12,9 @@ import Icon from "../Icon";
|
||||
|
||||
// Utils
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useSidebar } from "@/Hooks/useSidebar.js";
|
||||
|
||||
const URL_MAP = {
|
||||
support: "https://discord.com/invite/NAb6H3UTjK",
|
||||
@@ -65,8 +65,7 @@ const Sidebar = () => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
// Redux state
|
||||
const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false);
|
||||
const { collapsed, width, transition } = useSidebar();
|
||||
|
||||
const menu = getMenu(t);
|
||||
const otherMenuItems = getOtherMenuItems(t);
|
||||
@@ -75,20 +74,19 @@ const Sidebar = () => {
|
||||
return (
|
||||
<Stack
|
||||
height="100vh"
|
||||
width={
|
||||
collapsed
|
||||
? "var(--env-var-side-bar-collapsed-width)"
|
||||
: "var(--env-var-side-bar-width)"
|
||||
}
|
||||
width={width}
|
||||
component="aside"
|
||||
position="sticky"
|
||||
position="fixed"
|
||||
top={0}
|
||||
borderRight={`1px solid ${theme.palette.primary.lowContrast}`}
|
||||
left={0}
|
||||
paddingTop={theme.spacing(6)}
|
||||
paddingBottom={theme.spacing(6)}
|
||||
gap={theme.spacing(6)}
|
||||
sx={{
|
||||
transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)",
|
||||
transition,
|
||||
backgroundColor: theme.palette.background.main,
|
||||
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<CollapseButton collapsed={collapsed} />
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from "react";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
export type ActionMenuItem = {
|
||||
id: number | string;
|
||||
label: React.ReactNode;
|
||||
action: Function;
|
||||
closeMenu?: boolean;
|
||||
};
|
||||
|
||||
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | any>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(e.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent<any>) => {
|
||||
e.stopPropagation();
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<IconButton onClick={handleClick}>
|
||||
<Settings
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
|
||||
e.stopPropagation();
|
||||
if (item.closeMenu) handleClose(e);
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { getResponseColor } from "@/Utils/DataUtils";
|
||||
import { HeatmapResponseTimeTooltip } from "@/Components/v2/common/charts/HeatmapResponseTimeTooltip";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
|
||||
interface PlaceholderCheck {
|
||||
status: "placeholder";
|
||||
responseTime: 0;
|
||||
createdAt: "";
|
||||
}
|
||||
|
||||
interface HeatmapResponseTimeProps {
|
||||
checks: CheckSnapshot[];
|
||||
gap?: ResponsiveStyleValue<number | string>;
|
||||
availabilityCellSx?: SxProps;
|
||||
responseCellSx?: SxProps;
|
||||
}
|
||||
|
||||
export const HeatmapResponseTime = ({
|
||||
checks,
|
||||
gap,
|
||||
availabilityCellSx,
|
||||
responseCellSx,
|
||||
}: HeatmapResponseTimeProps) => {
|
||||
const theme = useTheme();
|
||||
if (!gap) {
|
||||
gap = theme.spacing(0.5);
|
||||
}
|
||||
|
||||
if (!checks || checks.length === 0) return null;
|
||||
|
||||
const latestChecks = checks.slice(-25).reverse();
|
||||
|
||||
let data: Array<CheckSnapshot | PlaceholderCheck>;
|
||||
if (latestChecks.length !== 25) {
|
||||
const placeholders = Array(25 - latestChecks.length).fill({
|
||||
status: "placeholder" as const,
|
||||
});
|
||||
data = [...latestChecks, ...placeholders];
|
||||
} else {
|
||||
data = latestChecks;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gap,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{data.map((check, index) => {
|
||||
const statusBg =
|
||||
check.status === "placeholder"
|
||||
? theme.palette.action.hover
|
||||
: check.status === true
|
||||
? theme.palette.success.light
|
||||
: theme.palette.error.light;
|
||||
const respBg =
|
||||
check.status === "placeholder"
|
||||
? theme.palette.action.hover
|
||||
: getResponseColor(check.responseTime || 0, {
|
||||
start: theme.palette.success.main,
|
||||
mid: theme.palette.warning.main,
|
||||
end: theme.palette.error.main,
|
||||
});
|
||||
const statusBorder =
|
||||
check.status === "placeholder"
|
||||
? theme.palette.divider
|
||||
: check.status === true
|
||||
? theme.palette.success.main
|
||||
: theme.palette.error.main;
|
||||
|
||||
return (
|
||||
<HeatmapResponseTimeTooltip
|
||||
key={`${check}-${index}`}
|
||||
check={check}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
aspectRatio: "10",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
bgcolor: statusBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...availabilityCellSx,
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
borderRadius: theme.spacing(0.5),
|
||||
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
bgcolor: respBg,
|
||||
borderRadius: theme.spacing(0.5),
|
||||
...responseCellSx,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</HeatmapResponseTimeTooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
type HeatmapCheck =
|
||||
| CheckSnapshot
|
||||
| { status: "placeholder"; responseTime: 0; createdAt: "" };
|
||||
|
||||
export const HeatmapResponseTimeTooltip = ({
|
||||
children,
|
||||
check,
|
||||
}: {
|
||||
children: React.ReactElement;
|
||||
check: HeatmapCheck;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
|
||||
const getColor = (status: boolean) => {
|
||||
if (status === true) return theme.palette.success.light;
|
||||
if (status === false) return theme.palette.error.light;
|
||||
};
|
||||
|
||||
if (check.status === "placeholder") {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
backgroundColor: "transparent",
|
||||
color: "inherit",
|
||||
boxShadow: "none",
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={
|
||||
<Stack
|
||||
sx={{
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
p: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
{check?.responseTime && (
|
||||
<Typography>
|
||||
{t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Typography textTransform={"capitalize"}>
|
||||
Status:{" "}
|
||||
<span style={{ color: getColor(check?.status) }}>
|
||||
{check?.status === true
|
||||
? t("pages.common.monitors.status.up")
|
||||
: t("pages.common.monitors.status.down")}
|
||||
</span>
|
||||
</Typography>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
|
||||
|
||||
interface HistogramResponseTimeProps {
|
||||
checks: CheckSnapshot[];
|
||||
height?: ResponsiveStyleValue<number | string>;
|
||||
gap?: ResponsiveStyleValue<number | string>;
|
||||
}
|
||||
|
||||
const DEFAULT_HEIGHT = 50;
|
||||
|
||||
export const HistogramResponseTime = ({
|
||||
checks,
|
||||
height = DEFAULT_HEIGHT,
|
||||
gap,
|
||||
}: HistogramResponseTimeProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (!Array.isArray(checks) || checks.length === 0) return null;
|
||||
|
||||
let data = Array<any>();
|
||||
|
||||
if (!checks || checks.length === 0) {
|
||||
data = [];
|
||||
}
|
||||
if (checks.length !== 25) {
|
||||
const placeholders = Array(25 - checks.length).fill({
|
||||
status: "placeholder",
|
||||
});
|
||||
data = [...checks, ...placeholders];
|
||||
} else {
|
||||
data = checks;
|
||||
}
|
||||
const chartHeight = typeof height === "number" ? `${height}px` : height;
|
||||
const gridGap = gap ?? theme.spacing(0.5);
|
||||
return (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(25, 1fr)",
|
||||
gap: gridGap,
|
||||
alignItems: "end",
|
||||
height: chartHeight,
|
||||
}}
|
||||
>
|
||||
{data.map((check, index) => {
|
||||
const isPlaceholder = (check as any).status === "placeholder";
|
||||
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
|
||||
const barColor =
|
||||
check.status === true
|
||||
? theme.palette.success.light
|
||||
: theme.palette.error.light;
|
||||
const bar = (
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: theme.spacing(1),
|
||||
bgcolor: theme.palette.action.hover,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: isPlaceholder ? 0 : heightPct,
|
||||
bgcolor: barColor,
|
||||
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return isPlaceholder ? (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
{bar}
|
||||
</Box>
|
||||
) : (
|
||||
<HeatmapResponseTimeTooltip
|
||||
key={index}
|
||||
check={check}
|
||||
>
|
||||
{bar}
|
||||
</HeatmapResponseTimeTooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ButtonInput } from "../../inputs/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HeaderCreateProps {
|
||||
label?: string;
|
||||
path: string;
|
||||
isLoading?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export const HeaderCreate = ({
|
||||
label,
|
||||
path,
|
||||
isLoading = false,
|
||||
isAdmin = false,
|
||||
}: HeaderCreateProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
width="100%"
|
||||
>
|
||||
<ButtonInput
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleClick}
|
||||
loading={isLoading}
|
||||
>
|
||||
{label || t("common.buttons.create")}
|
||||
</ButtonInput>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Icon, MonitorStatus } from "@/Components/v2/design-elements";
|
||||
import { Button } from "@/Components/v2/inputs";
|
||||
import { Settings, Pause, Play, Mail, Bug } from "lucide-react";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { usePost } from "@/Hooks/UseApi";
|
||||
|
||||
import type { Monitor } from "@/Types/Monitor.js";
|
||||
|
||||
interface HeaderMonitorControlsProps {
|
||||
path: string;
|
||||
monitor?: Monitor;
|
||||
isAdmin: boolean;
|
||||
refetch: Function;
|
||||
}
|
||||
|
||||
export const HeaderMonitorControls = ({
|
||||
path,
|
||||
monitor,
|
||||
isAdmin,
|
||||
refetch,
|
||||
}: HeaderMonitorControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const {
|
||||
post,
|
||||
loading: isPosting,
|
||||
// error: postError,
|
||||
} = usePost<any, Monitor>();
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
spacing={{ xs: theme.spacing(8), md: 0 }}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
alignItems={"center"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<MonitorStatus monitor={monitor} />
|
||||
<Stack
|
||||
width={{ xs: "100%", md: "auto" }}
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
loading={isPosting}
|
||||
startIcon={<Icon icon={Mail} />}
|
||||
onClick={async () => {
|
||||
await post(`/notifications/test/all`, { monitorId: monitor.id });
|
||||
}}
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.testNotifications")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<Icon icon={Bug} />}
|
||||
onClick={() => {
|
||||
navigate(`/incidents/${monitor?.id}`);
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.incidents")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
loading={isPosting}
|
||||
startIcon={monitor?.isActive ? <Icon icon={Pause} /> : <Icon icon={Play} />}
|
||||
onClick={async () => {
|
||||
await post(`/monitors/pause/${monitor.id}`, {});
|
||||
await refetch();
|
||||
}}
|
||||
>
|
||||
{monitor?.isActive ? t("pause") : t("resume")}
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
startIcon={<Icon icon={Settings} />}
|
||||
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface MonitorTimeFrameHeaderProps {
|
||||
isLoading?: boolean;
|
||||
hasDateRange?: boolean;
|
||||
dateRange: string;
|
||||
setDateRange: (dateRange: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderTimeRange = ({
|
||||
isLoading = false,
|
||||
hasDateRange = true,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
}: MonitorTimeFrameHeaderProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const handleChange = (
|
||||
_event: React.MouseEvent<HTMLElement>,
|
||||
newValue: string | null
|
||||
) => {
|
||||
if (newValue !== null) {
|
||||
setDateRange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
let timeFramePicker = null;
|
||||
|
||||
if (hasDateRange) {
|
||||
timeFramePicker = (
|
||||
<ToggleButtonGroup
|
||||
value={dateRange}
|
||||
exclusive
|
||||
onChange={handleChange}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="recent"
|
||||
>
|
||||
Recent
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="day"
|
||||
>
|
||||
Day
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="week"
|
||||
>
|
||||
Week
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
disabled={isLoading}
|
||||
value="month"
|
||||
>
|
||||
Month
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
Showing statistics for past{" "}
|
||||
{dateRange === "recent"
|
||||
? "2 hours"
|
||||
: dateRange === "day"
|
||||
? "24 hours"
|
||||
: dateRange === "week"
|
||||
? "7 days"
|
||||
: "30 days"}
|
||||
.
|
||||
</Typography>
|
||||
{timeFramePicker}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "../common/charts/HistogramResponseTime";
|
||||
export * from "../common/charts/HeatmapResponseTime";
|
||||
export * from "../common/charts/HeatmapResponseTimeTooltip";
|
||||
export * from "../common/controls/HeaderCreate";
|
||||
export * from "../common/controls/HeaderMonitorControls";
|
||||
export * from "../common/controls/HeaderTimeRange";
|
||||
@@ -0,0 +1,19 @@
|
||||
import Box, { type BoxProps } from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BaseBox } from "../design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import type { ResponsiveStyleValue } from "@mui/system";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type BaseChartProps = React.PropsWithChildren<{
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
width?: number | string;
|
||||
maxWidth?: number | string;
|
||||
padding?: number | string | ResponsiveStyleValue<number | string>;
|
||||
}>;
|
||||
|
||||
export const BaseChart = ({
|
||||
children,
|
||||
icon,
|
||||
title,
|
||||
width = "100%",
|
||||
maxWidth = "100%",
|
||||
padding,
|
||||
}: BaseChartProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
padding: padding ?? theme.spacing(8),
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
width: width,
|
||||
maxWidth: maxWidth,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(8)}
|
||||
flex={1}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
{icon && (
|
||||
<BaseBox
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
"& svg": {
|
||||
width: 20,
|
||||
height: 20,
|
||||
"& path": {
|
||||
stroke: theme.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</BaseBox>
|
||||
)}
|
||||
<Typography variant="h2">{title}</Typography>
|
||||
</Stack>
|
||||
<Box flex={1}>{children}</Box>
|
||||
</Stack>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Link from "@mui/material/Link";
|
||||
import {
|
||||
ErrorFallback,
|
||||
EmptyFallback,
|
||||
EmptyMonitorFallback,
|
||||
BaseFallback,
|
||||
} from "@/Components/v2/design-elements/Fallback";
|
||||
import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
export const PageSpeedKeyPriorityFallback = () => {
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxWidth: 600,
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
<Trans
|
||||
i18nKey="common.alerts.pageSpeedApiKey.content"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/settings"
|
||||
color="inherit"
|
||||
fontWeight="inherit"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Alert>
|
||||
</BaseFallback>
|
||||
);
|
||||
};
|
||||
|
||||
interface BasePageProps extends StackProps {
|
||||
loading?: boolean;
|
||||
error?: boolean;
|
||||
children: React.ReactNode;
|
||||
breadcrumbOverride?: string[];
|
||||
}
|
||||
|
||||
export const BasePage = ({
|
||||
loading,
|
||||
error,
|
||||
children,
|
||||
breadcrumbOverride,
|
||||
...props
|
||||
}: BasePageProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
<CircularProgress
|
||||
color="primary"
|
||||
size={28}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorFallback
|
||||
title="Something went wrong..."
|
||||
subtitle="Please try again later"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
spacing={theme.spacing(10)}
|
||||
{...props}
|
||||
>
|
||||
<Breadcrumb />
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface BasePageWithStatesProps extends StackProps {
|
||||
loading: boolean;
|
||||
error: any;
|
||||
items: any[];
|
||||
bullets: string[] | unknown;
|
||||
page: string;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BasePageWithStates = ({
|
||||
loading,
|
||||
error,
|
||||
items,
|
||||
page,
|
||||
bullets,
|
||||
actionButtonText,
|
||||
actionLink,
|
||||
children,
|
||||
...props
|
||||
}: BasePageWithStatesProps) => {
|
||||
const showLoading = loading && (!items || items.length === 0);
|
||||
|
||||
if (isEmpty(items)) {
|
||||
return (
|
||||
<EmptyFallback
|
||||
bullets={bullets}
|
||||
title={page}
|
||||
actionButtonText={actionButtonText}
|
||||
actionLink={actionLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
loading={showLoading}
|
||||
error={error}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
interface MonitorBasePageWithStatesProps extends StackProps {
|
||||
loading: boolean;
|
||||
error: any;
|
||||
items: any[];
|
||||
page: string;
|
||||
actionLink?: string;
|
||||
children: React.ReactNode;
|
||||
priorityFallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
const isEmpty = (items: any[]) => {
|
||||
if (!items) return true;
|
||||
if (Array.isArray(items) && items.length === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const MonitorBasePageWithStates = ({
|
||||
loading,
|
||||
error,
|
||||
items,
|
||||
page,
|
||||
actionLink,
|
||||
children,
|
||||
priorityFallback,
|
||||
...props
|
||||
}: MonitorBasePageWithStatesProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showLoading = loading && (!items || items.length === 0);
|
||||
|
||||
if (priorityFallback) {
|
||||
return (
|
||||
<BasePage
|
||||
loading={loading}
|
||||
error={error}
|
||||
{...props}
|
||||
>
|
||||
{priorityFallback}
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && isEmpty(items)) {
|
||||
return (
|
||||
<EmptyMonitorFallback
|
||||
page={page}
|
||||
title={t(`pages.${page}.fallback.title`)}
|
||||
bullets={t(`pages.${page}.fallback.checks`, { returnObjects: true })}
|
||||
actionButtonText={t(`pages.${page}.fallback.actionButton`)}
|
||||
actionLink={actionLink || ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
loading={showLoading}
|
||||
error={error}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import MuiBreadcrumbs from "@mui/material/Breadcrumbs";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
const isId = (segment: string): boolean => {
|
||||
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
|
||||
};
|
||||
|
||||
export const Breadcrumb = () => {
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
|
||||
const segments = location.pathname.split("/").filter((x) => x);
|
||||
|
||||
// Build simplified breadcrumb: "uptime" or "uptime / details"
|
||||
const basePage = segments[0] || "home";
|
||||
const hasDetailsPage = segments.length > 1 && isId(segments[1]);
|
||||
|
||||
return (
|
||||
<MuiBreadcrumbs
|
||||
separator={
|
||||
<ChevronRight
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
marginBottom: theme.spacing(6),
|
||||
"& .MuiBreadcrumbs-separator": {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasDetailsPage ? (
|
||||
<Link
|
||||
to={`/${basePage}`}
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
color: theme.palette.text.secondary,
|
||||
"&:hover": {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
||||
</Typography>
|
||||
</Link>
|
||||
) : (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: theme.palette.primary.main,
|
||||
}}
|
||||
>
|
||||
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
|
||||
</Typography>
|
||||
)}
|
||||
{hasDetailsPage && (
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
color: theme.palette.primary.main,
|
||||
}}
|
||||
>
|
||||
Details
|
||||
</Typography>
|
||||
)}
|
||||
</MuiBreadcrumbs>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const BulletPointCheck = ({
|
||||
text,
|
||||
variant = "info",
|
||||
}: {
|
||||
text: string;
|
||||
noHighlightText?: string;
|
||||
variant?: "success" | "error" | "info";
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const colors: Record<string, string | undefined> = {
|
||||
success: theme.palette.success.main,
|
||||
error: theme.palette.error.main,
|
||||
info: theme.palette.text.secondary,
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(6)}
|
||||
alignItems="center"
|
||||
>
|
||||
<Check
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
component="span"
|
||||
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
|
||||
fontWeight={450}
|
||||
sx={{
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
interface DotProps {
|
||||
color?: string;
|
||||
size?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
content: '""',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
opacity: 0.8,
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BulletPointCheck, SkeletonCard } from "@/Components/v2/design-elements";
|
||||
import { Button } from "@/Components/v2/inputs";
|
||||
|
||||
import { useNavigate } from "react-router";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { StackProps } from "@mui/material/Stack";
|
||||
|
||||
interface BaseFallbackProps extends StackProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
return (
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
margin={isSmall ? "inherit" : "auto"}
|
||||
marginTop={isSmall ? "33%" : "10%"}
|
||||
width={{
|
||||
sm: "90%",
|
||||
md: "70%",
|
||||
lg: "50%",
|
||||
xl: "40%",
|
||||
}}
|
||||
padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
|
||||
bgcolor={theme.palette.background.paper}
|
||||
border={1}
|
||||
borderColor={theme.palette.divider}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
sx={{
|
||||
borderStyle: "dashed",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SkeletonCard showHalo={true} />
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorFallback = ({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Typography
|
||||
variant="h1"
|
||||
marginY={theme.spacing(4)}
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography>{subtitle}</Typography>
|
||||
</BaseFallback>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyFallback = ({
|
||||
title,
|
||||
bullets,
|
||||
actionButtonText,
|
||||
actionLink,
|
||||
}: {
|
||||
title: string;
|
||||
bullets: any;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Stack
|
||||
gap={theme.spacing(10)}
|
||||
zIndex={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing(2),
|
||||
maxWidth: "1100px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{Array.isArray(bullets) &&
|
||||
bullets?.map((bullet: string) => (
|
||||
<BulletPointCheck
|
||||
text={bullet}
|
||||
key={`${bullet}-${Math.random()}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(actionLink)}
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BaseFallback>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyMonitorFallback = ({
|
||||
page,
|
||||
title,
|
||||
bullets,
|
||||
actionButtonText,
|
||||
actionLink,
|
||||
}: {
|
||||
page: string;
|
||||
title: string;
|
||||
bullets: any;
|
||||
actionButtonText: string;
|
||||
actionLink: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<BaseFallback>
|
||||
<Stack
|
||||
gap={theme.spacing(10)}
|
||||
zIndex={1}
|
||||
alignItems="center"
|
||||
>
|
||||
<Typography
|
||||
component="h1"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing(2),
|
||||
maxWidth: "1100px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{Array.isArray(bullets) &&
|
||||
bullets?.map((bullet: string, index: number) => (
|
||||
<BulletPointCheck
|
||||
text={bullet}
|
||||
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => navigate(actionLink)}
|
||||
>
|
||||
{actionButtonText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BaseFallback>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface IconProps {
|
||||
icon: LucideIcon;
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
|
||||
return (
|
||||
<Icon
|
||||
size={size}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { PulseDot, Dot } from "@/Components/v2/design-elements";
|
||||
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stack>
|
||||
<Typography
|
||||
fontSize={typographyLevels.xl}
|
||||
fontWeight={500}
|
||||
color={theme.palette.primary.contrastText}
|
||||
overflow={"hidden"}
|
||||
textOverflow={"ellipsis"}
|
||||
whiteSpace={"nowrap"}
|
||||
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
|
||||
>
|
||||
{monitor.name}
|
||||
</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(4)}
|
||||
>
|
||||
<PulseDot color={getStatusColor(monitor.status, theme)} />
|
||||
<Typography
|
||||
fontSize={typographyLevels.l}
|
||||
fontWeight={"bolder"}
|
||||
fontFamily={"monospace"}
|
||||
overflow={"hidden"}
|
||||
textOverflow={"ellipsis"}
|
||||
whiteSpace={"nowrap"}
|
||||
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
|
||||
>
|
||||
{formatUrl(monitor?.url)}
|
||||
</Typography>
|
||||
{!isSmall && (
|
||||
<>
|
||||
<Dot />
|
||||
<Typography>
|
||||
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
|
||||
interface PulseDotProps {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const PulseDot = ({ color }: PulseDotProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
width="26px"
|
||||
height="24px"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
minWidth="18px"
|
||||
minHeight="18px"
|
||||
sx={{
|
||||
position: "relative",
|
||||
backgroundColor: color,
|
||||
borderRadius: "50%",
|
||||
"&::before": {
|
||||
content: `""`,
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "inherit",
|
||||
borderRadius: "50%",
|
||||
animation: "ripple 1.8s ease-out infinite",
|
||||
},
|
||||
"&::after": {
|
||||
content: `""`,
|
||||
position: "absolute",
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Box, Stack, useTheme } from "@mui/material";
|
||||
|
||||
interface SkeletonCardProps {
|
||||
width?: number | string | { xs?: number | string; md?: number | string };
|
||||
showHalo?: boolean;
|
||||
}
|
||||
|
||||
export const SkeletonCard = ({
|
||||
width = { xs: 130, md: 216 },
|
||||
showHalo = true,
|
||||
}: SkeletonCardProps) => {
|
||||
const theme = useTheme();
|
||||
const pulseAnimation = {
|
||||
"@keyframes pulse": {
|
||||
"0%, 100%": { opacity: 1 },
|
||||
"50%": { opacity: 0.72 },
|
||||
},
|
||||
};
|
||||
|
||||
const floatAnimation = {
|
||||
"@keyframes float": {
|
||||
"0%, 100%": { transform: "rotate(5deg) translate(12px, -20px)" },
|
||||
"50%": { transform: "rotate(5deg) translate(12px, -24px)" },
|
||||
},
|
||||
};
|
||||
|
||||
const cardStyle = {
|
||||
width: width,
|
||||
height: 57,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background:
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.secondary.dark
|
||||
: theme.palette.background.paper,
|
||||
boxShadow:
|
||||
theme.palette.mode === "dark"
|
||||
? "0 8px 30px rgba(0,0,0,.3), 0 0 0 1px rgba(255,255,255,.05) inset"
|
||||
: "0 8px 30px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05) inset",
|
||||
display: "flex",
|
||||
gap: theme.spacing(5),
|
||||
alignItems: "center",
|
||||
padding: theme.spacing(5),
|
||||
};
|
||||
|
||||
const blockStyle = {
|
||||
width: 55,
|
||||
height: 34,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
animation: "pulse 1.6s ease-in-out infinite",
|
||||
...pulseAnimation,
|
||||
};
|
||||
|
||||
const lineStyle = {
|
||||
height: 7,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
background: theme.palette.secondary.main,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
animation: "pulse 1.6s ease-in-out infinite",
|
||||
...pulseAnimation,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position={"relative"}
|
||||
mt={20}
|
||||
mb={20}
|
||||
>
|
||||
{showHalo && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
pointerEvents: "none",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
width: "260px",
|
||||
height: "260px",
|
||||
borderRadius: "999px",
|
||||
background:
|
||||
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "#fff",
|
||||
filter: "blur(60px)",
|
||||
opacity: 0.9,
|
||||
},
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Card stack */}
|
||||
<Box sx={{ position: "relative", zIndex: 1 }}>
|
||||
{/* Front card (now at bottom) */}
|
||||
<Box sx={cardStyle}>
|
||||
<Box sx={blockStyle} />
|
||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
||||
{/* Empty space to maintain card height */}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Back card A */}
|
||||
<Box
|
||||
sx={{
|
||||
...cardStyle,
|
||||
transform: "rotate(-4deg) translate(-12px, -12px)",
|
||||
filter: "blur(.2px)",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Box sx={blockStyle} />
|
||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
||||
<Box sx={{ ...lineStyle, width: "60%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "80%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "40%" }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Back card B */}
|
||||
<Box
|
||||
sx={{
|
||||
...cardStyle,
|
||||
transform: "rotate(5deg) translate(12px, -20px)",
|
||||
filter: "blur(.3px)",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
animation: "float 3s ease-in-out 5",
|
||||
...floatAnimation,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Box sx={blockStyle} />
|
||||
<Stack sx={{ flex: 1, gap: "12px" }}>
|
||||
<Box sx={{ ...lineStyle, width: "60%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "80%" }} />
|
||||
<Box sx={{ ...lineStyle, width: "40%" }} />
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
|
||||
import { BaseBox, TooltipWithInfo } from "@/Components/v2/design-elements";
|
||||
import type { SxProps } from "@mui/material";
|
||||
|
||||
type GradientBox = React.PropsWithChildren<{
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
}>;
|
||||
|
||||
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isLight = theme.palette.mode === "light";
|
||||
const paper = theme.palette.background.paper;
|
||||
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
|
||||
const paperEnd = paper;
|
||||
const bg = palette
|
||||
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
|
||||
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
|
||||
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
|
||||
background: bg,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
type StatBoxProps = React.PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
palette?: PaletteKey;
|
||||
sx?: SxProps;
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}>;
|
||||
|
||||
export const StatBox = ({
|
||||
title,
|
||||
subtitle,
|
||||
palette,
|
||||
children,
|
||||
sx,
|
||||
tooltip,
|
||||
onClick,
|
||||
}: StatBoxProps) => {
|
||||
const theme = useTheme();
|
||||
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
|
||||
|
||||
return (
|
||||
<GradientBox
|
||||
palette={palette}
|
||||
sx={{
|
||||
...(sx as object),
|
||||
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
|
||||
}}
|
||||
>
|
||||
<Stack onClick={onClick}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
|
||||
<Typography color={textColor}>{title}</Typography>
|
||||
{tooltip && (
|
||||
<TooltipWithInfo
|
||||
title={tooltip}
|
||||
iconColor={textColor as string}
|
||||
iconSize={14}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography color={textColor}>{subtitle}</Typography>
|
||||
{children}
|
||||
</Stack>
|
||||
</GradientBox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/v2/design-elements";
|
||||
import Background from "@/assets/Images/background-grid.svg?react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>;
|
||||
|
||||
export const BGBox = ({ children }: StatusBoxProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
padding: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-10%"
|
||||
left="5%"
|
||||
>
|
||||
<Background />
|
||||
</Box>
|
||||
{children}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
const StatusBox = ({
|
||||
label,
|
||||
n,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
n: number;
|
||||
color: string | undefined;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BGBox>
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
<Typography
|
||||
variant={"h2"}
|
||||
textTransform="uppercase"
|
||||
color={theme.palette.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h1"
|
||||
color={color}
|
||||
>
|
||||
{n}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</BGBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.up")}
|
||||
n={n}
|
||||
color={theme.palette.success.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DownStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.down")}
|
||||
n={n}
|
||||
color={theme.palette.error.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PausedStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.paused")}
|
||||
n={n}
|
||||
color={theme.palette.warning.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InitializingStatusBox = ({ n }: { n: number }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<StatusBox
|
||||
label={t("pages.common.monitors.status.initializing")}
|
||||
n={n}
|
||||
color={theme.palette.warning.light}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import { BaseBox } from "@/Components/v2/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import type { MonitorStatus } from "@/Types/Monitor";
|
||||
import type { SxProps } from "@mui/material/styles";
|
||||
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const ValueTypes = ["positive", "negative", "neutral"] as const;
|
||||
export type ValueType = (typeof ValueTypes)[number];
|
||||
|
||||
export const StatusLabel = ({
|
||||
status,
|
||||
isActive,
|
||||
sx,
|
||||
}: {
|
||||
status: MonitorStatus;
|
||||
isActive?: boolean;
|
||||
sx?: SxProps;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const palette = getStatusPalette(status);
|
||||
|
||||
const determineStatus = (
|
||||
isActive: boolean | undefined,
|
||||
status: MonitorStatus
|
||||
): string => {
|
||||
if (isActive === false) {
|
||||
return t("pages.common.monitors.status.paused");
|
||||
}
|
||||
|
||||
if (status === true) {
|
||||
return t("pages.common.monitors.status.up");
|
||||
}
|
||||
if (status === false) {
|
||||
return t("pages.common.monitors.status.down");
|
||||
}
|
||||
return t("pages.common.monitors.status.initializing");
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing(3, 5),
|
||||
color: theme.palette[palette].main,
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.08)"
|
||||
: "rgba(0, 0, 0, 0.08)",
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={7}
|
||||
height={7}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
<Typography textTransform={"capitalize"}>
|
||||
{determineStatus(isActive, status)}
|
||||
</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) => {
|
||||
const theme = useTheme();
|
||||
const palette = getValuePalette(value);
|
||||
const transformedText = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
|
||||
return (
|
||||
<BaseBox
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: theme.spacing(3, 5),
|
||||
color: theme.palette[palette].main,
|
||||
borderColor:
|
||||
theme.palette.mode === "dark"
|
||||
? "rgba(255, 255, 255, 0.08)"
|
||||
: "rgba(0, 0, 0, 0.08)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
width={7}
|
||||
height={7}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
borderRadius="50%"
|
||||
marginRight="5px"
|
||||
/>
|
||||
{transformedText}
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,577 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Grid2 from "@mui/material/Grid2";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import Collapse from "@mui/material/Collapse";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Coffee,
|
||||
Ellipsis,
|
||||
PartyPopper,
|
||||
} from "lucide-react";
|
||||
|
||||
import TablePagination from "@mui/material/TablePagination";
|
||||
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, Fragment } from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
export type Header<T> = {
|
||||
id: number | string;
|
||||
content: React.ReactNode;
|
||||
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
|
||||
render: (row: T) => React.ReactNode;
|
||||
};
|
||||
|
||||
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
|
||||
headers: Header<T>[];
|
||||
data: T[];
|
||||
onRowClick?: (row: T) => void;
|
||||
cardsOnSmallScreens?: boolean;
|
||||
expandableRows?: boolean;
|
||||
renderExpandedContent?: (row: T) => React.ReactNode;
|
||||
emptyViewText?: string;
|
||||
emptyViewPositive?: boolean;
|
||||
};
|
||||
|
||||
export function DataTable<
|
||||
T extends {
|
||||
id?: string | number;
|
||||
_id?: string | number;
|
||||
onRowClick?: (row: T) => void;
|
||||
},
|
||||
>({
|
||||
headers,
|
||||
data,
|
||||
onRowClick,
|
||||
cardsOnSmallScreens = true,
|
||||
expandableRows = false,
|
||||
renderExpandedContent,
|
||||
emptyViewText,
|
||||
emptyViewPositive,
|
||||
}: DataTableProps<T>) {
|
||||
const theme = useTheme();
|
||||
const [expanded, setExpanded] = useState<(string | number) | null>(null);
|
||||
const handleExpand = (row: T) => {
|
||||
const key = row.id || row._id || null;
|
||||
setExpanded(expanded === key ? null : key);
|
||||
};
|
||||
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
if (data.length === 0 || headers.length === 0) {
|
||||
return (
|
||||
<EmptyView
|
||||
text={emptyViewText}
|
||||
positive={emptyViewPositive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const keys = [];
|
||||
// Return stack of cards for small screens
|
||||
if (isSmall && cardsOnSmallScreens) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(4)}>
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
keys.push(key);
|
||||
return (
|
||||
<Stack
|
||||
onClick={() => (onRowClick ? onRowClick(row) : null)}
|
||||
spacing={theme.spacing(4)}
|
||||
sx={{
|
||||
borderStyle: "solid",
|
||||
borderWidth: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(4),
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
}}
|
||||
key={key}
|
||||
>
|
||||
{headers.map((header) => {
|
||||
return (
|
||||
<Grid2
|
||||
container
|
||||
key={header.id}
|
||||
>
|
||||
<Grid2
|
||||
size={5}
|
||||
display={"flex"}
|
||||
alignItems={"center"}
|
||||
>
|
||||
<Typography
|
||||
component="div"
|
||||
color={theme.palette.text.primary}
|
||||
>
|
||||
{header.content}
|
||||
</Typography>
|
||||
</Grid2>
|
||||
<Grid2
|
||||
size={7}
|
||||
display="flex"
|
||||
alignItems={"center"}
|
||||
>
|
||||
{header.render(row)}{" "}
|
||||
</Grid2>
|
||||
</Grid2>
|
||||
);
|
||||
})}
|
||||
{expandableRows && renderExpandedContent && renderExpandedContent(row) && (
|
||||
<Grid2 size={12}>{renderExpandedContent(row)}</Grid2>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
elevation={0}
|
||||
sx={{ boxShadow: "none", overflowX: "hidden" }}
|
||||
>
|
||||
<Table
|
||||
sx={{
|
||||
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
|
||||
paddingLeft: theme.spacing(8),
|
||||
},
|
||||
"& .MuiTableCell-root": {
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
},
|
||||
"& .MuiTableHead-root .MuiTableRow-root": {
|
||||
height: "28px",
|
||||
},
|
||||
"& :is(th)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
textTransform: "uppercase",
|
||||
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
|
||||
fontSize: theme.typography.fontSize,
|
||||
},
|
||||
"& :is(td)": {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.secondary,
|
||||
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
|
||||
fontSize: theme.typography.fontSize,
|
||||
overflowX: "hidden",
|
||||
},
|
||||
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
|
||||
borderBottom: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map((header, idx) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={idx === 0 ? "left" : "center"}
|
||||
key={header.id}
|
||||
>
|
||||
{header.content}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((row) => {
|
||||
const key = row.id || row._id || Math.random();
|
||||
const isExpanded = expanded === key;
|
||||
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<TableRow
|
||||
sx={{
|
||||
cursor: onRowClick ? "pointer" : "default",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (expandableRows) handleExpand(row);
|
||||
else if (onRowClick) onRowClick(row);
|
||||
}}
|
||||
>
|
||||
{headers.map((header, index) => {
|
||||
return (
|
||||
<TableCell
|
||||
align={index === 0 ? "left" : "center"}
|
||||
key={header.id}
|
||||
onClick={
|
||||
header.onClick ? (e) => header.onClick!(e, row) : undefined
|
||||
}
|
||||
>
|
||||
{header.render(row)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
{expandableRows &&
|
||||
renderExpandedContent &&
|
||||
renderExpandedContent(row) && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={headers.length}
|
||||
style={{
|
||||
borderBottom: isExpanded ? undefined : "none",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
in={isExpanded}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
>
|
||||
<Box sx={{ pt: 4, pb: 4 }}>{renderExpandedContent(row)}</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface TablePaginationActionsProps {
|
||||
count: number;
|
||||
page: number;
|
||||
rowsPerPage: number;
|
||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||
}
|
||||
|
||||
function TablePaginationActions(props: TablePaginationActionsProps) {
|
||||
const theme = useTheme();
|
||||
const { count, page, rowsPerPage, onPageChange } = props;
|
||||
|
||||
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, 0);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page - 1);
|
||||
};
|
||||
|
||||
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page + 1);
|
||||
};
|
||||
|
||||
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
|
||||
className="table-pagination-actions"
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronsLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="next page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleLastPageButtonClick}
|
||||
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
|
||||
aria-label="last page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronsRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface HasMoreTablePaginationActionsProps {
|
||||
hasMore?: boolean;
|
||||
page: number;
|
||||
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
|
||||
}
|
||||
function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) {
|
||||
const theme = useTheme();
|
||||
const { hasMore, page, onPageChange } = props;
|
||||
|
||||
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, 0);
|
||||
};
|
||||
|
||||
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page - 1);
|
||||
};
|
||||
|
||||
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onPageChange(event, page + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
|
||||
className="table-pagination-actions"
|
||||
>
|
||||
<IconButton
|
||||
onClick={handleFirstPageButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="first page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronsRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronsLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleBackButtonClick}
|
||||
disabled={page === 0}
|
||||
aria-label="previous page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={handleNextButtonClick}
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
>
|
||||
{theme.direction === "rtl" ? (
|
||||
<ChevronLeft
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
<Tooltip title={hasMore === true ? "More pages available" : "No more pages"}>
|
||||
<IconButton
|
||||
disabled={hasMore === false}
|
||||
aria-label="next page"
|
||||
>
|
||||
<Ellipsis
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaginationProps extends TablePaginationOwnProps {
|
||||
component?: React.ElementType;
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
export const Pagination = ({ ...props }: PaginationProps) => {
|
||||
const { hasMore, ...rest } = props;
|
||||
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
|
||||
const theme = useTheme();
|
||||
const labelDisplayedRows = ({
|
||||
from,
|
||||
to,
|
||||
count,
|
||||
}: {
|
||||
from: number;
|
||||
to: number;
|
||||
count: number;
|
||||
}) => {
|
||||
if (hasMore) {
|
||||
return to === 0 ? "" : `${from}–${to}`;
|
||||
}
|
||||
return `${from}–${to} of ${count}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TablePagination
|
||||
ActionsComponent={(props) =>
|
||||
typeof hasMore === "boolean" ? (
|
||||
<HasMoreTablePaginationActions
|
||||
{...props}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
) : (
|
||||
<TablePaginationActions {...props} />
|
||||
)
|
||||
}
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
labelDisplayedRows={labelDisplayedRows}
|
||||
{...rest}
|
||||
sx={{
|
||||
"& .MuiTablePagination-toolbar": isSmall
|
||||
? {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gridAutoRows: "auto",
|
||||
rowGap: theme.spacing(1),
|
||||
alignItems: "center",
|
||||
justifyItems: "center",
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}
|
||||
: { display: "flex", alignItems: "center" },
|
||||
"& .MuiTablePagination-selectLabel": {
|
||||
gridColumn: "1",
|
||||
gridRow: "1",
|
||||
justifySelf: "center",
|
||||
},
|
||||
"& .MuiTablePagination-select": {
|
||||
gridColumn: "2",
|
||||
gridRow: "1",
|
||||
justifySelf: "center",
|
||||
},
|
||||
"& .MuiTablePagination-displayedRows": isSmall
|
||||
? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" }
|
||||
: {},
|
||||
"& .table-pagination-actions": isSmall
|
||||
? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" }
|
||||
: {},
|
||||
"& .MuiSelect-select": {
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const Icon = positive ? PartyPopper : Coffee;
|
||||
return (
|
||||
<Stack
|
||||
alignItems={"center"}
|
||||
justifyContent={"center"}
|
||||
sx={{
|
||||
py: theme.spacing(12),
|
||||
px: theme.spacing(4),
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
mb: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={28}
|
||||
strokeWidth={1}
|
||||
color={theme.palette.text.secondary}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
color={theme.palette.text.primary}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{text ?? t("common.table.empty")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import MuiTooltip from "@mui/material/Tooltip";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import type { TooltipProps } from "@mui/material/Tooltip";
|
||||
|
||||
type StyledTooltipProps = TooltipProps;
|
||||
|
||||
export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<MuiTooltip
|
||||
title={props.title}
|
||||
placement={placement}
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
background:
|
||||
theme.palette.mode === "dark"
|
||||
? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
|
||||
: "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
|
||||
backgroundColor: "transparent",
|
||||
color: "#ffffff",
|
||||
fontSize: "13px",
|
||||
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</MuiTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TooltipWithInfoProps extends Omit<StyledTooltipProps, "children"> {
|
||||
iconSize?: number;
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
export const TooltipWithInfo = ({
|
||||
iconSize = 14,
|
||||
iconColor,
|
||||
...props
|
||||
}: TooltipWithInfoProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const defaultColor = theme.palette.text.secondary;
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
cursor: "help",
|
||||
}}
|
||||
>
|
||||
<Info
|
||||
size={iconSize}
|
||||
strokeWidth={1.5}
|
||||
style={{
|
||||
opacity: 0.7,
|
||||
color: iconColor || defaultColor,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = "0.7";
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from "./Breadcrumb";
|
||||
export * from "./BasePage";
|
||||
export * from "./Fallback";
|
||||
export * from "./SkeletonCard";
|
||||
export * from "./BulletPointCheck";
|
||||
export * from "./StatusBox";
|
||||
export * from "./BaseBox";
|
||||
export * from "./Table";
|
||||
export { DataTable as Table } from "./Table";
|
||||
export * from "./StatusLabel";
|
||||
export * from "./PulseDot";
|
||||
export * from "./Dot";
|
||||
export * from "./MonitorStatus";
|
||||
export { default as Icon } from "./Icon";
|
||||
export * from "./Tooltip";
|
||||
export * from "./StatBox";
|
||||
export * from "./BaseChart";
|
||||
@@ -0,0 +1,88 @@
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import type { AutocompleteProps } from "@mui/material/Autocomplete";
|
||||
import { TextField, Checkbox } from "@/Components/v2/inputs";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
type AutoCompleteInputProps = Omit<
|
||||
AutocompleteProps<any, boolean, boolean, boolean>,
|
||||
"renderInput"
|
||||
> & {
|
||||
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export const AutoCompleteInput = ({
|
||||
fieldLabel,
|
||||
required,
|
||||
renderInput,
|
||||
...props
|
||||
}: AutoCompleteInputProps) => {
|
||||
const theme = useTheme();
|
||||
const multiple = props.multiple;
|
||||
|
||||
const defaultRenderInput = (params: any) => (
|
||||
<TextField
|
||||
{...params}
|
||||
placeholder="Type to search"
|
||||
/>
|
||||
);
|
||||
|
||||
const autocomplete = (
|
||||
<Autocomplete
|
||||
{...props}
|
||||
disableCloseOnSelect={!!multiple}
|
||||
popupIcon={
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
style={{ marginRight: theme.spacing(3) }}
|
||||
/>
|
||||
}
|
||||
renderInput={renderInput || defaultRenderInput}
|
||||
getOptionKey={(option) => option.id}
|
||||
renderTags={() => null}
|
||||
renderOption={(props, option, { selected }) => {
|
||||
const { key, ...optionProps } = props;
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
{...optionProps}
|
||||
>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
alignItems={"center"}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
{multiple && <Checkbox checked={selected} />}
|
||||
{option.name}
|
||||
</Stack>
|
||||
</ListItem>
|
||||
);
|
||||
}}
|
||||
sx={{
|
||||
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
|
||||
padding: `0 ${theme.spacing(5)}`,
|
||||
},
|
||||
"& .MuiInputBase-root .MuiAutocomplete-endAdornment": {
|
||||
right: theme.spacing(3),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{autocomplete}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return autocomplete;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import type { ButtonProps } from "@mui/material/Button";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
|
||||
const theme = useTheme();
|
||||
const outlinedSx =
|
||||
props.variant === "outlined"
|
||||
? {
|
||||
color: theme.palette.text.primary,
|
||||
borderColor: theme.palette.divider,
|
||||
"&:hover": {
|
||||
borderColor: theme.palette.text.secondary,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
fontWeight: 400,
|
||||
borderRadius: 2,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
boxShadow: "none",
|
||||
"&:hover": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
...outlinedSx,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { forwardRef } from "react";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import type { CheckboxProps } from "@mui/material/Checkbox";
|
||||
import { Square, SquareCheck } from "lucide-react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
type CheckboxInputProps = CheckboxProps & {
|
||||
label?: string;
|
||||
};
|
||||
export const CheckboxInput = forwardRef<HTMLInputElement, CheckboxInputProps>(
|
||||
function CheckboxInput(
|
||||
{ sx, ...props }: CheckboxInputProps,
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Checkbox
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
slotProps={{
|
||||
input: {
|
||||
ref: ref,
|
||||
},
|
||||
}}
|
||||
icon={
|
||||
<Square
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
checkedIcon={
|
||||
<SquareCheck
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
color: theme.palette.text.secondary,
|
||||
"&.Mui-checked": {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
"&:hover": { backgroundColor: "transparent" },
|
||||
"& svg": {
|
||||
stroke: "currentColor",
|
||||
},
|
||||
"& svg path, & svg line, & svg polyline, & svg rect": {
|
||||
stroke: "currentColor",
|
||||
fill: "none",
|
||||
},
|
||||
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import { Button } from "@/Components/v2/inputs";
|
||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const DialogInput = ({
|
||||
open,
|
||||
title,
|
||||
content,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmColor = "primary",
|
||||
cancelColor = "error",
|
||||
loading = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
content?: string;
|
||||
onConfirm?(item: any): any;
|
||||
onCancel?(item: any): any;
|
||||
confirmColor?: "error" | "primary";
|
||||
cancelColor?: "error" | "primary";
|
||||
loading?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>{content}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
color={cancelColor}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
color={confirmColor}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("common.buttons.confirm")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
interface FieldLabelProps {
|
||||
children: React.ReactNode;
|
||||
required?: boolean;
|
||||
htmlFor?: string;
|
||||
}
|
||||
|
||||
export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Typography
|
||||
component="label"
|
||||
htmlFor={htmlFor}
|
||||
sx={{
|
||||
fontSize: "14px",
|
||||
fontWeight: 500,
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{required && (
|
||||
<span
|
||||
style={{
|
||||
color: theme.palette.error.main,
|
||||
marginLeft: theme.spacing(2),
|
||||
}}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import Select from "@mui/material/Select";
|
||||
import React from "react";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { SelectProps } from "@mui/material/Select";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
placeholderColor?: string;
|
||||
}
|
||||
|
||||
export const SelectInput = <T,>({
|
||||
fieldLabel,
|
||||
required,
|
||||
placeholder,
|
||||
placeholderColor,
|
||||
...props
|
||||
}: SelectInputProps<T>) => {
|
||||
const theme = useTheme();
|
||||
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
|
||||
|
||||
const renderValue = (selected: any) => {
|
||||
const isMultiple = Boolean((props as any).multiple);
|
||||
const isEmpty = isMultiple
|
||||
? !Array.isArray(selected) || selected.length === 0
|
||||
: selected === undefined || selected === null || selected === "";
|
||||
|
||||
if (isEmpty && placeholder) {
|
||||
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
const items: string[] = Array.isArray(selected) ? selected : [];
|
||||
const capitalized = items.map(
|
||||
(item) => item.charAt(0).toUpperCase() + item.slice(1)
|
||||
);
|
||||
return (<Typography>{capitalized.join(" | ")}</Typography>) as any;
|
||||
}
|
||||
|
||||
const nodes = React.Children.toArray(props.children as React.ReactNode);
|
||||
for (const node of nodes) {
|
||||
if (!React.isValidElement(node)) continue;
|
||||
const el = node as React.ReactElement<{
|
||||
value?: unknown;
|
||||
children?: React.ReactNode;
|
||||
}>;
|
||||
if (el.props?.value === selected) {
|
||||
return (el.props.children ?? selected) as React.ReactNode;
|
||||
}
|
||||
}
|
||||
return selected as React.ReactNode;
|
||||
};
|
||||
|
||||
const select = (
|
||||
<Select<T>
|
||||
{...props}
|
||||
displayEmpty
|
||||
renderValue={renderValue}
|
||||
inputProps={{
|
||||
...(props.inputProps || {}),
|
||||
"aria-placeholder": placeholder,
|
||||
}}
|
||||
IconComponent={() => (
|
||||
<ChevronDown
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
style={{ marginRight: theme.spacing(3) }}
|
||||
/>
|
||||
)}
|
||||
sx={{
|
||||
height: "34px",
|
||||
"& .MuiSelect-icon": {
|
||||
right: theme.spacing(3),
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{select}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return select;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { forwardRef } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import type { TextFieldProps } from "@mui/material";
|
||||
import { typographyLevels } from "@/Utils/Theme/v2Palette";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { FieldLabel } from "./FieldLabel";
|
||||
|
||||
interface TextInputProps extends Omit<TextFieldProps, "label"> {
|
||||
fieldLabel?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
|
||||
{ fieldLabel, required, ...props },
|
||||
ref
|
||||
) {
|
||||
const theme = useTheme();
|
||||
|
||||
const input = (
|
||||
<TextField
|
||||
{...props}
|
||||
inputRef={ref}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
height: 34,
|
||||
fontSize: typographyLevels.base,
|
||||
},
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"&:hover .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: theme.palette.divider,
|
||||
},
|
||||
"& .MuiFormHelperText-root": {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (fieldLabel) {
|
||||
return (
|
||||
<Stack spacing={theme.spacing(2)}>
|
||||
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
|
||||
{input}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return input;
|
||||
});
|
||||
TextInput.displayName = "TextInput";
|
||||
@@ -0,0 +1,52 @@
|
||||
import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
import type { ToggleButtonProps } from "@mui/material/ToggleButton";
|
||||
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
|
||||
return (
|
||||
<ToggleButton
|
||||
{...props}
|
||||
sx={{
|
||||
px: 8,
|
||||
textTransform: "none",
|
||||
height: 34,
|
||||
fontWeight: 400,
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
boxShadow: "none",
|
||||
"&:hover": {
|
||||
boxShadow: "none",
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<ToggleButtonGroup
|
||||
{...props}
|
||||
sx={{
|
||||
"& .MuiToggleButtonGroup-grouped": {
|
||||
borderColor: theme.palette.divider,
|
||||
|
||||
borderRadius: 0,
|
||||
"&:first-of-type": {
|
||||
borderTopLeftRadius: 2,
|
||||
borderBottomLeftRadius: 2,
|
||||
},
|
||||
"&:last-of-type": {
|
||||
borderTopRightRadius: 2,
|
||||
borderBottomRightRadius: 2,
|
||||
},
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export { ButtonInput as Button } from "./Button";
|
||||
export * from "./FieldLabel";
|
||||
export { SelectInput as Select } from "./Select";
|
||||
export { CheckboxInput as Checkbox } from "./Checkbox";
|
||||
export { AutoCompleteInput as Autocomplete } from "./AutoComplete";
|
||||
export { TextInput as TextField } from "./TextInput";
|
||||
export {
|
||||
ToggleButtonInput as ToggleButton,
|
||||
ToggleButtonGroupInput as ToggleButtonGroup,
|
||||
} from "./ToggleButton";
|
||||
export { DialogInput as Dialog } from "./Dialog";
|
||||
@@ -0,0 +1,92 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Select, Button } from "@/Components/v2/inputs";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import type { MonitorType } from "@/Types/Monitor";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
|
||||
const types = ["http", "ping", "port", "docker"];
|
||||
const statuses = ["up", "down"];
|
||||
const states = ["active", "paused"];
|
||||
|
||||
export const ControlsFilter = ({
|
||||
selectedTypes,
|
||||
setSelectedTypes,
|
||||
selectedStatus,
|
||||
setSelectedStatus,
|
||||
selectedState,
|
||||
setSelectedState,
|
||||
onClearFilters,
|
||||
}: {
|
||||
selectedTypes: MonitorType[];
|
||||
setSelectedTypes: React.Dispatch<React.SetStateAction<MonitorType[]>>;
|
||||
selectedStatus: string;
|
||||
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedState: string;
|
||||
setSelectedState: React.Dispatch<React.SetStateAction<string>>;
|
||||
onClearFilters: () => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isFilterActive =
|
||||
selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== "";
|
||||
return (
|
||||
<Stack
|
||||
direction={isSmall ? "column" : "row"}
|
||||
gap={theme.spacing(2)}
|
||||
>
|
||||
<Select
|
||||
multiple
|
||||
placeholder="Type"
|
||||
value={selectedTypes}
|
||||
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
|
||||
>
|
||||
{types.map((type) => (
|
||||
<MenuItem
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
<Typography textTransform={"capitalize"}>{type}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<MenuItem
|
||||
key={status}
|
||||
value={status}
|
||||
>
|
||||
<Typography textTransform={"capitalize"}>{status}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="State"
|
||||
value={selectedState}
|
||||
onChange={(e) => setSelectedState(e.target.value)}
|
||||
>
|
||||
{states.map((state) => (
|
||||
<MenuItem
|
||||
key={state}
|
||||
value={state}
|
||||
>
|
||||
<Typography textTransform={"capitalize"}>{state}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{isFilterActive && (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { StatBox } from "@/Components/v2/design-elements";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { MonitorStats, Monitor } from "@/Types/Monitor";
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
|
||||
import { getStatusPalette } from "@/Utils/MonitorUtils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MonitorStatBoxesProps {
|
||||
monitor?: Monitor;
|
||||
monitorStats: MonitorStats | null;
|
||||
certificateExpiry?: string;
|
||||
}
|
||||
|
||||
export const MonitorStatBoxes = ({
|
||||
monitor,
|
||||
monitorStats,
|
||||
certificateExpiry,
|
||||
}: MonitorStatBoxesProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
if (!monitorStats || !monitor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
|
||||
const timeSinceLastFailure =
|
||||
timeOfLastFailure > 0
|
||||
? Date.now() - timeOfLastFailure
|
||||
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
|
||||
|
||||
// Determine time since last check
|
||||
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
|
||||
const timeSinceLastCheck = Date.now() - timeOfLastCheck;
|
||||
|
||||
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
|
||||
|
||||
const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck);
|
||||
const palette = getStatusPalette(monitor?.status);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: "column", md: "row" }}
|
||||
gap={theme.spacing(8)}
|
||||
>
|
||||
<StatBox
|
||||
palette={palette}
|
||||
title={t("pages.common.monitors.statBoxes.activeFor")}
|
||||
subtitle={streakTime}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.lastCheck")}
|
||||
subtitle={lastCheckTime}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
|
||||
subtitle={monitorStats?.lastResponseTime + " ms"}
|
||||
/>
|
||||
|
||||
{monitor?.type === "http" && (
|
||||
<StatBox
|
||||
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
|
||||
subtitle={certificateExpiry || "N/A"}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { BaseChart, BaseBox } from "@/Components/v2/design-elements";
|
||||
import { Clock } from "lucide-react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Text,
|
||||
} from "recharts";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
formatDateWithTz,
|
||||
tickDateFormatLookup,
|
||||
tooltipDateFormatLookup,
|
||||
} from "@/Utils/TimeUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { GroupedCheck } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
type XTickProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: any };
|
||||
range: string;
|
||||
};
|
||||
|
||||
export const XTick = ({ x, y, payload, range }: XTickProps) => {
|
||||
const format = tickDateFormatLookup(range);
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
return (
|
||||
<Text
|
||||
x={x}
|
||||
y={y + 10}
|
||||
textAnchor="middle"
|
||||
fill={theme.palette.text.secondary}
|
||||
fontSize={11}
|
||||
fontWeight={400}
|
||||
>
|
||||
{formatDateWithTz(payload?.value, format, uiTimezone)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
type ResponseTimeToolTipProps = {
|
||||
active?: boolean | undefined;
|
||||
payload?: any[];
|
||||
label?: string | number;
|
||||
range: string;
|
||||
theme: any;
|
||||
uiTimezone: string;
|
||||
};
|
||||
|
||||
const ResponseTimeToolTip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
range,
|
||||
theme,
|
||||
uiTimezone,
|
||||
}: ResponseTimeToolTipProps) => {
|
||||
if (!label) return null;
|
||||
if (!payload) return null;
|
||||
if (!active) return null;
|
||||
|
||||
const format = tooltipDateFormatLookup(range);
|
||||
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
|
||||
return (
|
||||
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
|
||||
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
|
||||
<Typography>Response time: {responseTime} ms</Typography>
|
||||
</BaseBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const HistogramDetails = ({
|
||||
checks,
|
||||
range,
|
||||
}: {
|
||||
checks: GroupedCheck[];
|
||||
range: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
return (
|
||||
<BaseChart
|
||||
icon={
|
||||
<Clock
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
title="Response times"
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={300}
|
||||
>
|
||||
<AreaChart data={checks?.slice()}>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.divider}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="colorUv"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={theme.palette.primary.main}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={theme.palette.primary.light}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
dataKey="bucketDate"
|
||||
tick={(props) => (
|
||||
<XTick
|
||||
{...props}
|
||||
range={range}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={(props) => (
|
||||
<ResponseTimeToolTip
|
||||
{...props}
|
||||
range={range}
|
||||
theme={theme}
|
||||
uiTimezone={uiTimezone}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="avgResponseTime"
|
||||
stroke={theme.palette.primary.main}
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { HistogramPageSpeedTooltip } from "@/Components/v2/monitors/charts/HistogramPageSpeedTooltip";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import type { MonitorStatus } from "@/Types/Monitor";
|
||||
|
||||
import { getStatusColor } from "@/Utils/MonitorUtils";
|
||||
|
||||
const processData = (checks: CheckSnapshot[]) => {
|
||||
if (checks.length === 0) return [];
|
||||
const formattedData: { score: number; date: string }[] = [];
|
||||
|
||||
const calculateScore = (entry: CheckSnapshot) => {
|
||||
const accessibility = entry?.accessibility || 0;
|
||||
const bestPractices = entry?.bestPractices || 0;
|
||||
const performance = entry?.performance || 0;
|
||||
const seo = entry?.seo || 0;
|
||||
return Math.floor((accessibility + bestPractices + performance + seo) / 4);
|
||||
};
|
||||
|
||||
checks.forEach((entry) => {
|
||||
const score = calculateScore(entry);
|
||||
const date = entry.createdAt;
|
||||
formattedData.push({ score, date });
|
||||
});
|
||||
|
||||
return formattedData;
|
||||
};
|
||||
|
||||
export const HistogramPageSpeed = ({
|
||||
checks,
|
||||
status,
|
||||
}: {
|
||||
checks: CheckSnapshot[];
|
||||
status: MonitorStatus;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const scores = processData(checks ?? []);
|
||||
const color = getStatusColor(status, theme);
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
flexWrap="nowrap"
|
||||
gap={theme.spacing(1.5)}
|
||||
height="100px"
|
||||
maxWidth="400px"
|
||||
width="100%"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
overflow={"hidden"}
|
||||
sx={{
|
||||
cursor: "default",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={"100%"}
|
||||
>
|
||||
<AreaChart
|
||||
data={scores}
|
||||
margin={{ top: 10, bottom: -5 }}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.divider}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.divider }}
|
||||
content={<HistogramPageSpeedTooltip />}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`pagespeed-chart-${status}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={color}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
stroke={color}
|
||||
fill={`url(#pagespeed-chart-${status})`}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { BaseChart } from "@/Components/v2/design-elements";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { XTick } from "@/Components/v2/monitors/";
|
||||
import {
|
||||
XAxis,
|
||||
AreaChart,
|
||||
Area,
|
||||
Tooltip,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { HistogramPageSpeedScoresTooltip } from "@/Components/v2/monitors";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import type { Palette } from "@mui/material/styles";
|
||||
type PaletteColorKey = Extract<
|
||||
keyof Palette,
|
||||
"primary" | "error" | "success" | "warning"
|
||||
>;
|
||||
|
||||
export interface ConfigItem {
|
||||
id: string;
|
||||
text: string;
|
||||
palette: PaletteColorKey;
|
||||
}
|
||||
|
||||
const config: Record<string, ConfigItem> = {
|
||||
seo: {
|
||||
id: "seo",
|
||||
text: "SEO",
|
||||
palette: "primary",
|
||||
},
|
||||
performance: {
|
||||
id: "performance",
|
||||
text: "performance",
|
||||
palette: "success",
|
||||
},
|
||||
bestPractices: {
|
||||
id: "bestPractices",
|
||||
text: "best practices",
|
||||
palette: "warning",
|
||||
},
|
||||
accessibility: {
|
||||
id: "accessibility",
|
||||
text: "accessibility",
|
||||
palette: "error",
|
||||
},
|
||||
};
|
||||
|
||||
export const HistogramPageSpeedDetails = ({
|
||||
checks,
|
||||
range,
|
||||
}: {
|
||||
checks: CheckSnapshot[];
|
||||
range: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<BaseChart
|
||||
icon={
|
||||
<TrendingUp
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
title="Score history"
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
minWidth={25}
|
||||
height={215}
|
||||
>
|
||||
<AreaChart data={checks}>
|
||||
<XAxis
|
||||
dataKey={"createdAt"}
|
||||
tick={(props) => (
|
||||
<XTick
|
||||
{...props}
|
||||
range={range}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CartesianGrid
|
||||
stroke={theme.palette.divider}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={1}
|
||||
fill="transparent"
|
||||
vertical={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: theme.palette.divider }}
|
||||
content={<HistogramPageSpeedScoresTooltip config={config} />}
|
||||
/>
|
||||
<defs>
|
||||
{Object.values(config).map(({ id, palette }) => {
|
||||
const startColor = theme.palette[palette].main;
|
||||
const endColor = theme.palette[palette].light;
|
||||
|
||||
return (
|
||||
<linearGradient
|
||||
id={id}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
key={id}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={startColor}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={endColor}
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
{Object.keys(config).map((key) => {
|
||||
const { palette } = config[key];
|
||||
const strokeColor = theme.palette[palette].main;
|
||||
const bgColor = theme.palette.primary.main;
|
||||
|
||||
return (
|
||||
<Area
|
||||
connectNulls
|
||||
key={key}
|
||||
dataKey={key}
|
||||
stackId={1}
|
||||
stroke={strokeColor}
|
||||
fill={`url(#${config[key].id})`}
|
||||
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { ConfigItem } from "@/Components/v2/monitors";
|
||||
import type { TooltipProps } from "recharts";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
interface HistogramPageSpeedScoresTooltipProps
|
||||
extends Partial<TooltipProps<number, string>> {
|
||||
config: Record<string, ConfigItem>;
|
||||
}
|
||||
export const HistogramPageSpeedScoresTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
config,
|
||||
}: HistogramPageSpeedScoresTooltipProps) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
py: theme.spacing(2),
|
||||
px: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
{Object.keys(config)
|
||||
.reverse()
|
||||
.map((key) => {
|
||||
const { palette } = config[key];
|
||||
const dotColor = theme.palette[palette].main;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
key={`${key}-tooltip`}
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(3)}
|
||||
mt={theme.spacing(1)}
|
||||
>
|
||||
<Box
|
||||
width={theme.spacing(4)}
|
||||
height={theme.spacing(4)}
|
||||
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
|
||||
/>
|
||||
<Typography
|
||||
textTransform="capitalize"
|
||||
sx={{ opacity: 0.8 }}
|
||||
>
|
||||
{config[key].text}
|
||||
</Typography>
|
||||
<Typography>{Math.floor(payload[0].payload[key])}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import type { TooltipProps } from "recharts";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
export const HistogramPageSpeedTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: TooltipProps<number, string>) => {
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const theme = useTheme();
|
||||
|
||||
if (!active || !payload || payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = payload[0]?.payload;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
alignItems="flex-start"
|
||||
sx={{
|
||||
backgroundColor: theme.palette.secondary.main,
|
||||
border: 1,
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
p: theme.spacing(4),
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
{formatDateWithTz(data?.date, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
|
||||
</Typography>
|
||||
<Typography>Score: {data?.score}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { BaseChart } from "@/Components/v2/design-elements";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useSelector } from "react-redux";
|
||||
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
|
||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||
import type { GroupedCheck } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
|
||||
interface XLabelProps {
|
||||
p1: GroupedCheck;
|
||||
p2: GroupedCheck;
|
||||
range: string;
|
||||
}
|
||||
|
||||
const XLabel = ({ p1, p2, range }: XLabelProps) => {
|
||||
const theme = useTheme();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
const dateFormat = range === "day" ? "MMM D, h:mm A" : "MMM D";
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={0}
|
||||
y="100%"
|
||||
dy={-3}
|
||||
textAnchor="start"
|
||||
fontSize={11}
|
||||
fill={theme.palette.text.secondary}
|
||||
>
|
||||
{formatDateWithTz(p1.bucketDate, dateFormat, uiTimezone)}
|
||||
</text>
|
||||
<text
|
||||
x="100%"
|
||||
y="100%"
|
||||
dy={-3}
|
||||
textAnchor="end"
|
||||
fontSize={11}
|
||||
fill={theme.palette.text.secondary}
|
||||
>
|
||||
{formatDateWithTz(p2.bucketDate, dateFormat, uiTimezone)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface HistogramStatusProps {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
checks?: GroupedCheck[];
|
||||
range: string;
|
||||
}
|
||||
|
||||
export const HistogramStatus = ({
|
||||
title,
|
||||
icon,
|
||||
checks = [],
|
||||
range,
|
||||
}: HistogramStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{ payload: GroupedCheck }>;
|
||||
}) => {
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
if (!active || !payload?.length) return null;
|
||||
const d = payload[0]?.payload;
|
||||
const avg = d?.avgResponseTime ?? 0;
|
||||
const titleText = t("common.charts.labels.averageResponseTime");
|
||||
const fmt = range === "30d" ? "MMM D, YYYY" : "ddd, MMM D, YYYY, h:mm A";
|
||||
let dateLabel = "";
|
||||
if (d?.bucketDate) {
|
||||
const base = new Date(d.bucketDate);
|
||||
const midBucket =
|
||||
range === "30d" ? new Date(base.getTime() + 12 * 60 * 60 * 1000) : base;
|
||||
dateLabel = formatDateWithTz(midBucket.toISOString(), fmt, uiTimezone);
|
||||
}
|
||||
return (
|
||||
<Stack
|
||||
sx={{
|
||||
p: 1,
|
||||
bgcolor: "background.paper",
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{dateLabel ? (
|
||||
<Typography
|
||||
sx={{ color: "text.secondary" }}
|
||||
variant="caption"
|
||||
>
|
||||
{dateLabel}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="caption">{titleText}</Typography>
|
||||
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const totalChecks = checks.reduce((acc, check) => acc + (check.totalChecks || 0), 0);
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
icon={icon}
|
||||
title={title}
|
||||
>
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Stack
|
||||
position="relative"
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Stack>
|
||||
<Typography>Total checks: {totalChecks}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={155}
|
||||
>
|
||||
<BarChart data={checks}>
|
||||
<XAxis
|
||||
stroke={theme.palette.divider}
|
||||
height={15}
|
||||
tick={false}
|
||||
label={
|
||||
<XLabel
|
||||
p1={checks[0]}
|
||||
p2={checks[checks.length - 1]}
|
||||
range={range}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={false}
|
||||
content={<CustomTooltip />}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="avgResponseTime"
|
||||
maxBarSize={7}
|
||||
background={{ fill: "transparent" }}
|
||||
>
|
||||
{checks?.map((groupedCheck, index) => {
|
||||
const fillColor = getResponseTimeColor(groupedCheck.avgResponseTime);
|
||||
return (
|
||||
<Cell
|
||||
key={groupedCheck.bucketDate || `check-${index}`}
|
||||
fill={theme.palette[fillColor].main}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Stack>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { BaseChart } from "@/Components/v2/design-elements";
|
||||
import { FileText } from "lucide-react";
|
||||
import type { CheckAudits, CheckSnapshot } from "@/Types/Check";
|
||||
import { Pie, PieChart, ResponsiveContainer, Label } from "recharts";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Box from "@mui/material/Box";
|
||||
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { useState } from "react";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const CenterLabel = ({ viewBox, value }: any) => {
|
||||
const { cx, cy } = viewBox;
|
||||
return (
|
||||
<foreignObject
|
||||
x={cx - 25}
|
||||
y={cy - 10}
|
||||
width={50}
|
||||
height={50}
|
||||
>
|
||||
<Typography
|
||||
variant="h1"
|
||||
align="center"
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
</foreignObject>
|
||||
);
|
||||
};
|
||||
|
||||
export const PiePageSpeed = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [hoverTitle, setHoverTitle] = useState<string | null>(null);
|
||||
|
||||
if (!latestCheck) return null;
|
||||
const LABELS: Record<string, string> = {
|
||||
FCP: t("pages.pageSpeed.charts.common.fcp"),
|
||||
SI: t("pages.pageSpeed.charts.common.si"),
|
||||
LCP: t("pages.pageSpeed.charts.common.lcp"),
|
||||
TBT: t("pages.pageSpeed.charts.common.tbt"),
|
||||
CLS: t("pages.pageSpeed.charts.common.cls"),
|
||||
};
|
||||
const metrics: { key: keyof CheckAudits; color: string; weight: number }[] = [
|
||||
{ key: "fcp", color: alpha("#1DE9B6", 0.6), weight: 0.1 },
|
||||
{ key: "si", color: alpha("#7C4DFF", 0.6), weight: 0.1 },
|
||||
{ key: "lcp", color: alpha("#FFB200", 0.6), weight: 0.25 },
|
||||
{ key: "tbt", color: alpha("#00AFFE", 0.6), weight: 0.3 },
|
||||
{ key: "cls", color: alpha("#FF4181", 0.6), weight: 0.25 },
|
||||
];
|
||||
|
||||
const scores = metrics.flatMap(({ key, color, weight }) => {
|
||||
const audit = latestCheck?.audits?.[key];
|
||||
const val = Math.floor((audit?.score ?? 0) * 100);
|
||||
const inverse = 100 - val;
|
||||
return [
|
||||
{
|
||||
name: `${key.toUpperCase()} Inverse`,
|
||||
value: inverse * weight,
|
||||
fill: "transparent",
|
||||
stroke: color,
|
||||
weight,
|
||||
},
|
||||
{
|
||||
name: key.toUpperCase(),
|
||||
value: val * weight,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
weight,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const totalScore =
|
||||
(latestCheck.audits?.fcp?.score || 0) * 0.1 +
|
||||
(latestCheck.audits?.si?.score || 0) * 0.1 +
|
||||
(latestCheck.audits?.lcp?.score || 0) * 0.25 +
|
||||
(latestCheck.audits?.tbt?.score || 0) * 0.3 +
|
||||
(latestCheck.audits?.cls?.score || 0) * 0.25;
|
||||
const pageSpeedPalette = getPageSpeedPalette(Math.floor(totalScore * 100));
|
||||
|
||||
const score = [
|
||||
{
|
||||
value: 100,
|
||||
fill: alpha(theme.palette[pageSpeedPalette].light || "#ffffff", 0.6),
|
||||
stroke: "none",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
icon={
|
||||
<FileText
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
title={t("pages.pageSpeed.charts.pie.title")}
|
||||
>
|
||||
<Tooltip
|
||||
open={Boolean(hoverTitle)}
|
||||
title={hoverTitle || ""}
|
||||
arrow
|
||||
followCursor
|
||||
placement="top"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
"& .recharts-wrapper:focus, & .recharts-surface:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
"& .recharts-wrapper *:focus": { outline: "none" },
|
||||
"& svg:focus, & g:focus, & path:focus": { outline: "none" },
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={250}
|
||||
>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={score}
|
||||
dataKey="value"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="65%"
|
||||
>
|
||||
<Label
|
||||
position={"center"}
|
||||
content={<CenterLabel value={Math.floor(totalScore * 100)} />}
|
||||
/>
|
||||
</Pie>
|
||||
<Pie
|
||||
data={scores}
|
||||
innerRadius="70%"
|
||||
outerRadius="80%"
|
||||
label={({ name, value }) => `${name}: ${Math.round(value)}`}
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
onMouseEnter={(_, index: number) => {
|
||||
const d = scores[index];
|
||||
if (!d) return;
|
||||
const isInverse = String(d.name).includes("Inverse");
|
||||
const pair = isInverse && scores[index + 1] ? scores[index + 1] : d;
|
||||
const base = String(pair.name).replace(" Inverse", "");
|
||||
const full = LABELS[base] ?? base;
|
||||
const displayVal = Math.round(Number(pair.value) || 0);
|
||||
setHoverTitle(`${full}: ${displayVal}`);
|
||||
}}
|
||||
onMouseLeave={() => setHoverTitle(null)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { BaseChart } from "@/Components/v2/design-elements";
|
||||
import Typography from "@mui/material/Typography";
|
||||
|
||||
import type { CheckSnapshot } from "@/Types/Check";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
const MetricBox = ({
|
||||
label,
|
||||
value,
|
||||
weight,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
weight: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const palette = getPageSpeedPalette(value);
|
||||
return (
|
||||
<Stack
|
||||
direction={"row"}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: theme.palette.divider,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
flex={1}
|
||||
p={theme.spacing(4)}
|
||||
>
|
||||
<Typography textTransform={"uppercase"}>{label}</Typography>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<Typography>{`${value}%`}</Typography>
|
||||
<Typography>{`${t("pages.pageSpeed.charts.legend.weight")}: ${weight}%`}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box
|
||||
width={4}
|
||||
bgcolor={theme.palette[palette].light}
|
||||
sx={{
|
||||
borderTopRightRadius: theme.shape.borderRadius,
|
||||
borderBottomRightRadius: theme.shape.borderRadius,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const PiePageSpeedLegend = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!latestCheck) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<BaseChart
|
||||
icon={
|
||||
<BarChart3
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
title={t("pages.pageSpeed.charts.legend.title")}
|
||||
>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<MetricBox
|
||||
label={t("pages.pageSpeed.charts.common.si")}
|
||||
value={Math.floor((latestCheck.audits?.si?.score || 0) * 100)}
|
||||
weight={10}
|
||||
/>
|
||||
<MetricBox
|
||||
label={t("pages.pageSpeed.charts.common.fcp")}
|
||||
value={Math.floor((latestCheck.audits?.fcp?.score || 0) * 100)}
|
||||
weight={10}
|
||||
/>
|
||||
<MetricBox
|
||||
label={t("pages.pageSpeed.charts.common.cls")}
|
||||
value={Math.floor((latestCheck.audits?.cls?.score || 0) * 100)}
|
||||
weight={25}
|
||||
/>
|
||||
<MetricBox
|
||||
label={t("pages.pageSpeed.charts.common.tbt")}
|
||||
value={Math.floor((latestCheck.audits?.tbt?.score || 0) * 100)}
|
||||
weight={30}
|
||||
/>
|
||||
<MetricBox
|
||||
label={t("pages.pageSpeed.charts.common.lcp")}
|
||||
value={Math.floor((latestCheck.audits?.lcp?.score || 0) * 100)}
|
||||
weight={25}
|
||||
/>
|
||||
</Stack>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { BaseChart } from "@/Components/v2/design-elements";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Gauge } from "lucide-react";
|
||||
import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const chartData = [
|
||||
{ name: "max", value: max - avg, color: "transparent" },
|
||||
{ name: "avg", value: avg, color: "red" },
|
||||
];
|
||||
|
||||
const palette = getResponseTimeColor(avg);
|
||||
const msg: Record<string, string> = {
|
||||
success: "Excellent",
|
||||
warning: "Average",
|
||||
danger: "Poor",
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseChart
|
||||
icon={
|
||||
<Gauge
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
}
|
||||
title={t("common.charts.labels.averageResponseTime")}
|
||||
>
|
||||
<Stack
|
||||
height="100%"
|
||||
position={"relative"}
|
||||
justifyContent={"flex-end"}
|
||||
>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={155}
|
||||
>
|
||||
<RadialBarChart
|
||||
cy="100%"
|
||||
data={chartData}
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
innerRadius={"120%"}
|
||||
outerRadius={"200%"}
|
||||
>
|
||||
<RadialBar
|
||||
dataKey="value"
|
||||
background={{ fill: theme.palette[palette].light }}
|
||||
>
|
||||
<Cell visibility={"hidden"} />
|
||||
<Cell fill={theme.palette[palette].main} />
|
||||
</RadialBar>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<Stack
|
||||
direction={"row"}
|
||||
justifyContent={"space-between"}
|
||||
>
|
||||
<Typography variant="body2">{t("common.charts.labels.low")}</Typography>
|
||||
<Typography variant="body2">{t("common.charts.labels.high")}</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
position="absolute"
|
||||
top={"50%"}
|
||||
right={"50%"}
|
||||
sx={{
|
||||
transform: "translate(50%, 0%)",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
textAlign={"center"}
|
||||
>
|
||||
{msg[palette]}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
textAlign={"center"}
|
||||
>{`${avg?.toFixed()}ms`}</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</BaseChart>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from "./ControlsFilter";
|
||||
export * from "./MonitorStatBoxes";
|
||||
export * from "./charts/HistogramStatus";
|
||||
export * from "./charts/RadialAvgResponse";
|
||||
export * from "./charts/HistogramDetails";
|
||||
export * from "./charts/HistogramPageSpeed";
|
||||
export * from "./charts/HistogramPageSpeedTooltip";
|
||||
export * from "./charts/PiePageSpeed";
|
||||
export * from "./charts/PiePageSpeedLegend";
|
||||
export * from "./charts/HistogramPageSpeedDetails";
|
||||
export * from "./charts/HistogramPageSpeedDetailsTooltip";
|
||||
@@ -10,21 +10,24 @@ const initialState = {
|
||||
msg: null,
|
||||
};
|
||||
|
||||
export const register = createAsyncThunk("auth/register", async (form, thunkApi) => {
|
||||
try {
|
||||
const res = await networkService.registerUser(form);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
if (error.response.data) {
|
||||
return thunkApi.rejectWithValue(error.response.data);
|
||||
export const register = createAsyncThunk(
|
||||
"auth/register",
|
||||
async ({ user, token }, thunkApi) => {
|
||||
try {
|
||||
const res = await networkService.registerUser(user, token);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
if (error.response.data) {
|
||||
return thunkApi.rejectWithValue(error.response.data);
|
||||
}
|
||||
const payload = {
|
||||
status: false,
|
||||
msg: error.message ? error.message : "Unknown error",
|
||||
};
|
||||
return thunkApi.rejectWithValue(payload);
|
||||
}
|
||||
const payload = {
|
||||
status: false,
|
||||
msg: error.message ? error.message : "Unknown error",
|
||||
};
|
||||
return thunkApi.rejectWithValue(payload);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
|
||||
try {
|
||||
|
||||
@@ -29,6 +29,7 @@ const initialState = {
|
||||
distributedUptimeEnabled: false,
|
||||
language: "en",
|
||||
starPromptOpen: true,
|
||||
chartType: "histogram",
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
@@ -73,6 +74,9 @@ const uiSlice = createSlice({
|
||||
setStarPromptOpen: (state, action) => {
|
||||
state.starPromptOpen = action.payload;
|
||||
},
|
||||
setChartType: (state, action) => {
|
||||
state.chartType = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,4 +92,5 @@ export const {
|
||||
setDistributedUptimeEnabled,
|
||||
setLanguage,
|
||||
setStarPromptOpen,
|
||||
setChartType,
|
||||
} = uiSlice.actions;
|
||||
@@ -0,0 +1,144 @@
|
||||
import useSWR from "swr";
|
||||
import type { SWRConfiguration } from "swr";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import { get, patch, post, deleteOp } from "@/Utils/ApiClient";
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/Hooks/UseToast";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
|
||||
const res = await get<ApiResponse<T>>(url, config);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const useGet = <T>(
|
||||
url: string | null,
|
||||
axiosConfig?: AxiosRequestConfig,
|
||||
swrConfig?: SWRConfiguration
|
||||
) => {
|
||||
const { data, error, isLoading, mutate } = useSWR<ApiResponse<T>>(
|
||||
url,
|
||||
(url: string) => fetcher<T>(url, axiosConfig),
|
||||
swrConfig
|
||||
);
|
||||
|
||||
return {
|
||||
data: data?.data ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePost = <B = any, R = any>() => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toastError, toastSuccess } = useToast();
|
||||
|
||||
const postFn = async (
|
||||
endpoint: string,
|
||||
body: B,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<R> | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await post<ApiResponse<R>>(endpoint, body, {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
},
|
||||
});
|
||||
toastSuccess(res.data?.msg || "Operation successful");
|
||||
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
||||
console.error(err);
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { post: postFn, loading, error };
|
||||
};
|
||||
|
||||
export const usePatch = <B = any, R = any>() => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toastError, toastSuccess } = useToast();
|
||||
|
||||
const patchFn = async (
|
||||
endpoint: string,
|
||||
body?: B,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<R> | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await patch<ApiResponse<R>>(endpoint, body, {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
},
|
||||
});
|
||||
toastSuccess(res.data?.msg || "Operation successful");
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { patch: patchFn, loading, error };
|
||||
};
|
||||
|
||||
export const useDelete = <R = any>() => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toastError, toastSuccess } = useToast();
|
||||
|
||||
const deleteFn = async (
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig
|
||||
): Promise<ApiResponse<R> | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await deleteOp<ApiResponse<R>>(endpoint, {
|
||||
...config,
|
||||
headers: {
|
||||
...config?.headers,
|
||||
},
|
||||
});
|
||||
toastSuccess(res.data?.msg || "Operation successful");
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
|
||||
toastError(errMsg);
|
||||
setError(errMsg);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { deleteFn, loading, error };
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { toast, type ToastOptions } from "react-toastify";
|
||||
|
||||
export const useToast = () => {
|
||||
const showToast = (message: string, options?: ToastOptions) => {
|
||||
toast.dismiss();
|
||||
const baseStyle: React.CSSProperties = {
|
||||
whiteSpace: "pre-line",
|
||||
wordBreak: "break-word",
|
||||
};
|
||||
toast(message, {
|
||||
...options,
|
||||
style: { ...baseStyle, ...(options?.style || {}) },
|
||||
});
|
||||
};
|
||||
|
||||
const toastSuccess = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "success" });
|
||||
const toastError = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "error" });
|
||||
const toastInfo = (msg: string, opts?: ToastOptions) =>
|
||||
showToast(msg, { ...opts, type: "info" });
|
||||
|
||||
return { showToast, toastSuccess, toastError, toastInfo };
|
||||
};
|
||||
@@ -137,7 +137,7 @@ const useFetchChecksByMonitor = ({
|
||||
return [checks, checksCount, isLoading, networkError];
|
||||
};
|
||||
|
||||
const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
|
||||
const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => {
|
||||
const [summary, setSummary] = useState(undefined);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
@@ -147,7 +147,7 @@ const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const res = await networkService.getChecksAndSummaryByTeamId();
|
||||
const res = await networkService.getChecksAndSummaryByTeamId({ dateRange });
|
||||
setSummary(res.data.data);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
@@ -158,7 +158,7 @@ const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [updateTrigger]);
|
||||
}, [dateRange, updateTrigger]);
|
||||
|
||||
return [summary, isLoading, networkError];
|
||||
};
|
||||
@@ -185,36 +185,9 @@ const useResolveIncident = () => {
|
||||
return [resolveIncident, isLoading];
|
||||
};
|
||||
|
||||
const useAcknowledgeChecks = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const acknowledge = async (setUpdateTrigger, monitorId = null) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (monitorId) {
|
||||
await networkService.updateMonitorChecksStatus({ monitorId, ack: true });
|
||||
} else {
|
||||
await networkService.updateAllChecksStatus({ ack: true });
|
||||
}
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
} catch (error) {
|
||||
const toastMessage = monitorId
|
||||
? t("checkHooks.failureResolveMonitor")
|
||||
: t("checkHooks.failureResolveAll");
|
||||
createToast({ body: toastMessage });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { acknowledge, isLoading };
|
||||
};
|
||||
|
||||
export {
|
||||
useFetchChecksByMonitor,
|
||||
useFetchChecksTeam,
|
||||
useFetchChecksSummaryByTeamId,
|
||||
useResolveIncident,
|
||||
useAcknowledgeChecks,
|
||||
};
|
||||
@@ -243,6 +243,7 @@ export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigg
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [networkError, setNetworkError] = useState(false);
|
||||
const [monitor, setMonitor] = useState(undefined);
|
||||
const [monitorStats, setMonitorStats] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitor = async () => {
|
||||
@@ -254,7 +255,8 @@ export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigg
|
||||
monitorId: monitorId,
|
||||
dateRange: dateRange,
|
||||
});
|
||||
setMonitor(response.data.data);
|
||||
setMonitor(response.data.data.monitor);
|
||||
setMonitorStats(response.data.data.monitorStats);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
} finally {
|
||||
@@ -263,7 +265,7 @@ export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigg
|
||||
};
|
||||
fetchMonitor();
|
||||
}, [monitorId, dateRange, updateTrigger]);
|
||||
return [monitor, isLoading, networkError];
|
||||
return [monitor, monitorStats, isLoading, networkError];
|
||||
};
|
||||
|
||||
export const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
|
||||
@@ -342,7 +344,6 @@ export const useDeleteMonitor = () => {
|
||||
const deleteMonitor = async ({ monitor, redirect }) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log(monitor);
|
||||
await networkService.deleteMonitorById({ monitorId: monitor.id });
|
||||
createToast({ body: "Monitor deleted successfully!" });
|
||||
if (redirect) {
|
||||
|
||||
@@ -102,7 +102,7 @@ const useGetNotificationById = (id, setNotification) => {
|
||||
const notificationData = {
|
||||
address: notification?.address,
|
||||
notificationName: notification?.notificationName,
|
||||
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?._id,
|
||||
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?.id,
|
||||
};
|
||||
|
||||
setNotification(notificationData);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
// CSS variable names for sidebar widths
|
||||
const SIDEBAR_WIDTH_VAR = "var(--env-var-side-bar-width)";
|
||||
const SIDEBAR_COLLAPSED_WIDTH_VAR = "var(--env-var-side-bar-collapsed-width)";
|
||||
|
||||
// Transition timing for sidebar width changes
|
||||
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
|
||||
|
||||
/**
|
||||
* Hook to get sidebar state and computed width
|
||||
* Centralizes sidebar width logic to avoid duplication between Sidebar and HomeLayout
|
||||
*
|
||||
* @returns {Object} Sidebar state and styles
|
||||
* @returns {boolean} collapsed - Whether the sidebar is collapsed
|
||||
* @returns {string} width - CSS width value based on collapsed state
|
||||
* @returns {string} transition - CSS transition for width changes
|
||||
*/
|
||||
export const useSidebar = () => {
|
||||
const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false);
|
||||
|
||||
return {
|
||||
collapsed,
|
||||
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
|
||||
transition: SIDEBAR_TRANSITION,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSidebar;
|
||||
@@ -167,7 +167,11 @@ const PasswordPanel = () => {
|
||||
value={localData.password}
|
||||
onChange={handleChange}
|
||||
error={errors[idToName["edit-current-password"]] ? true : false}
|
||||
helperText={errors[idToName["edit-current-password"]]}
|
||||
helperText={
|
||||
errors[idToName["edit-current-password"]]
|
||||
? t(errors[idToName["edit-current-password"]])
|
||||
: ""
|
||||
}
|
||||
endAdornment={<PasswordEndAdornment />}
|
||||
flex={1}
|
||||
/>
|
||||
@@ -193,7 +197,11 @@ const PasswordPanel = () => {
|
||||
value={localData.newPassword}
|
||||
onChange={handleChange}
|
||||
error={errors[idToName["edit-new-password"]] ? true : false}
|
||||
helperText={errors[idToName["edit-new-password"]]}
|
||||
helperText={
|
||||
errors[idToName["edit-new-password"]]
|
||||
? t(errors[idToName["edit-new-password"]])
|
||||
: ""
|
||||
}
|
||||
endAdornment={<PasswordEndAdornment />}
|
||||
flex={1}
|
||||
/>
|
||||
@@ -219,7 +227,11 @@ const PasswordPanel = () => {
|
||||
value={localData.confirm}
|
||||
onChange={handleChange}
|
||||
error={errors[idToName["edit-confirm-password"]] ? true : false}
|
||||
helperText={errors[idToName["edit-confirm-password"]]}
|
||||
helperText={
|
||||
errors[idToName["edit-confirm-password"]]
|
||||
? t(errors[idToName["edit-confirm-password"]])
|
||||
: ""
|
||||
}
|
||||
endAdornment={<PasswordEndAdornment />}
|
||||
flex={1}
|
||||
/>
|
||||
|
||||
@@ -210,7 +210,11 @@ const ProfilePanel = () => {
|
||||
autoComplete="given-name"
|
||||
onChange={handleChange}
|
||||
error={errors[idToName["edit-first-name"]] ? true : false}
|
||||
helperText={errors[idToName["edit-first-name"]]}
|
||||
helperText={
|
||||
errors[idToName["edit-first-name"]]
|
||||
? t(errors[idToName["edit-first-name"]])
|
||||
: ""
|
||||
}
|
||||
flex={1}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -228,7 +232,11 @@ const ProfilePanel = () => {
|
||||
value={localData.lastName}
|
||||
onChange={handleChange}
|
||||
error={errors[idToName["edit-last-name"]] ? true : false}
|
||||
helperText={errors[idToName["edit-last-name"]]}
|
||||
helperText={
|
||||
errors[idToName["edit-last-name"]]
|
||||
? t(errors[idToName["edit-last-name"]])
|
||||
: ""
|
||||
}
|
||||
flex={1}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -104,7 +104,7 @@ const TeamPanel = () => {
|
||||
|
||||
team = team.map((member) => ({
|
||||
...member,
|
||||
id: member._id,
|
||||
id: member.id,
|
||||
role: member.role.map((role) => ROLE_MAP[role]).join(","),
|
||||
}));
|
||||
setData(team);
|
||||
|
||||
@@ -176,7 +176,7 @@ const Register = ({ superAdminExists }) => {
|
||||
|
||||
delete toSubmit.confirm;
|
||||
|
||||
const action = await dispatch(register(toSubmit));
|
||||
const action = await dispatch(register({ user: toSubmit, token }));
|
||||
if (action.payload.success) {
|
||||
navigate("/uptime");
|
||||
createToast({
|
||||
|
||||
@@ -75,7 +75,7 @@ const PasswordTooltip = ({ feedback, form, children }) => {
|
||||
slotProps={{
|
||||
tooltip: {
|
||||
sx: {
|
||||
backgroundColor: theme.palette.tertiary.background,
|
||||
backgroundColor: theme.palette.tertiary.main,
|
||||
border: `0.5px solid ${theme.palette.primary.lowContrast}90`,
|
||||
borderRadius: theme.spacing(4),
|
||||
color: theme.palette.primary.contrastText,
|
||||
@@ -88,7 +88,7 @@ const PasswordTooltip = ({ feedback, form, children }) => {
|
||||
},
|
||||
arrow: {
|
||||
sx: {
|
||||
color: theme.palette.tertiary.background,
|
||||
color: theme.palette.tertiary.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//Components
|
||||
import Stack from "@mui/material/Stack";
|
||||
import DataTable from "@/Components/v1/Table/index.jsx";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
|
||||
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
|
||||
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
|
||||
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
|
||||
//Utils
|
||||
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetchChecksTeam, useFetchChecksByMonitor } from "@/Hooks/checkHooks.js";
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
|
||||
const GetTooltip = (row) => {
|
||||
const theme = useTheme();
|
||||
const phases = row?.timings?.phases;
|
||||
|
||||
const phaseKeyFormattingMap = {
|
||||
firstByte: "first byte",
|
||||
};
|
||||
return (
|
||||
<Stack
|
||||
backgroundColor={lighten(theme.palette.primary.main, 0.1)}
|
||||
border={`1px solid ${theme.palette.primary.lowContrast}`}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
py={theme.spacing(2)}
|
||||
px={theme.spacing(4)}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>{`Status code: ${row?.statusCode}`}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>{`Response time: ${row?.responseTime} ms`}</Typography>
|
||||
{phases && (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>{`Request timing: `}</Typography>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{ ml: theme.spacing(2), mt: theme.spacing(2) }}
|
||||
>
|
||||
<TableBody>
|
||||
{Object.keys(phases)?.map((phaseKey) => (
|
||||
<TableRow key={phaseKey}>
|
||||
<TableCell sx={{ border: "none", p: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="success"
|
||||
>
|
||||
{`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ border: "none", p: 0 }}>
|
||||
<Typography
|
||||
color={theme.palette.primary.contrastText}
|
||||
variant="body2"
|
||||
>{`${phases[phaseKey]} ms`}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const IncidentTable = ({
|
||||
isLoading,
|
||||
monitors,
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
updateTrigger,
|
||||
}) => {
|
||||
//Redux state
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
//Local state
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const selectedMonitorDetails = monitors?.[selectedMonitor];
|
||||
const selectedMonitorType = selectedMonitorDetails?.type;
|
||||
|
||||
//Hooks
|
||||
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
|
||||
useFetchChecksByMonitor({
|
||||
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
|
||||
type: selectedMonitorType,
|
||||
status: false,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter === "resolved" ? "all" : filter,
|
||||
ack: filter === "resolved" ? true : false,
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
enabled: selectedMonitor !== "0",
|
||||
updateTrigger,
|
||||
});
|
||||
|
||||
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
|
||||
useFetchChecksTeam({
|
||||
status: false,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter === "resolved" ? "all" : filter,
|
||||
ack: filter === "resolved" ? true : false,
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
enabled: selectedMonitor === "0",
|
||||
updateTrigger,
|
||||
});
|
||||
|
||||
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
|
||||
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
|
||||
isLoading = isLoadingTeam || isLoadingMonitor;
|
||||
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
//Handlers
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(event.target.value);
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: t("incidentsTableMonitorName"),
|
||||
render: (row) => {
|
||||
return monitors?.[row.metadata?.monitorId]?.name || "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("incidentsTableStatus"),
|
||||
render: (row) => {
|
||||
const status = row.status === true ? "up" : "down";
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={status}
|
||||
customStyles={{ textTransform: "capitalize" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dateTime",
|
||||
content: t("incidentsTableDateTime"),
|
||||
render: (row) => {
|
||||
const formattedDate = formatDateWithTz(
|
||||
row.createdAt,
|
||||
"YYYY-MM-DD HH:mm:ss A",
|
||||
uiTimezone
|
||||
);
|
||||
return formattedDate;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("incidentsTableStatusCode"),
|
||||
render: (row) => <HttpStatusLabel status={row.statusCode} />,
|
||||
},
|
||||
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
|
||||
];
|
||||
|
||||
if (isLoading) return <TableSkeleton />;
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && typeof checksCount === "undefined") {
|
||||
return <GenericFallback>{t("incidentsTableNoIncidents")}</GenericFallback>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
headers={headers}
|
||||
data={checks}
|
||||
config={{ tooltipContent: GetTooltip }}
|
||||
/>
|
||||
<Pagination
|
||||
paginationLabel={t("incidentsTablePaginationLabel")}
|
||||
itemCount={checksCount}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentTable.propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
monitors: PropTypes.object,
|
||||
selectedMonitor: PropTypes.string,
|
||||
filter: PropTypes.string,
|
||||
dateRange: PropTypes.string,
|
||||
updateTrigger: PropTypes.bool,
|
||||
setUpdateTrigger: PropTypes.func,
|
||||
};
|
||||
export default IncidentTable;
|
||||
+8
-7
@@ -5,6 +5,7 @@ import PropTypes from "prop-types";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import SkeletonLayout from "./skeleton.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OptionsHeader = ({
|
||||
@@ -14,27 +15,27 @@ const OptionsHeader = ({
|
||||
monitors,
|
||||
filter = "all",
|
||||
setFilter,
|
||||
dateRange = "all",
|
||||
dateRange = "hour",
|
||||
setDateRange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
|
||||
|
||||
const filterOptions = [
|
||||
{ _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") },
|
||||
{ _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") },
|
||||
{ _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") },
|
||||
{ _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") },
|
||||
{ id: "all", name: t("incidentsOptionsHeaderFilterAll") },
|
||||
{ id: "down", name: t("incidentsOptionsHeaderFilterDown") },
|
||||
{ id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") },
|
||||
{ id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") },
|
||||
];
|
||||
|
||||
// The stacks below which are three in number have the same style so
|
||||
const stackStyles = {
|
||||
direction: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(6),
|
||||
};
|
||||
|
||||
if (!shouldRender) return;
|
||||
if (!shouldRender) return <SkeletonLayout />;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,100 @@
|
||||
// Components
|
||||
import { Stack } from "@mui/material";
|
||||
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import IncidentTable from "./Components/IncidentTable/index.jsx";
|
||||
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
|
||||
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js";
|
||||
import { useFetchChecksSummaryByTeamId } from "@/Hooks/checkHooks.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
//Constants
|
||||
const Checks = () => {
|
||||
// Redux state
|
||||
const { t } = useTranslation();
|
||||
|
||||
const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }];
|
||||
|
||||
// Local state
|
||||
const [selectedMonitor, setSelectedMonitor] = useState("0");
|
||||
const [filter, setFilter] = useState(undefined);
|
||||
const [dateRange, setDateRange] = useState("hour");
|
||||
const [monitorLookup, setMonitorLookup] = useState(undefined);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
|
||||
//Hooks
|
||||
|
||||
//Utils
|
||||
const theme = useTheme();
|
||||
const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({});
|
||||
const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({
|
||||
dateRange,
|
||||
updateTrigger,
|
||||
});
|
||||
const { monitorId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (monitorId) {
|
||||
setSelectedMonitor(monitorId);
|
||||
}
|
||||
}, [monitorId]);
|
||||
|
||||
useEffect(() => {
|
||||
const monitorLookup = monitors?.reduce((acc, monitor) => {
|
||||
acc[monitor.id] = {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
type: monitor.type,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
setMonitorLookup(monitorLookup);
|
||||
}, [monitors]);
|
||||
|
||||
if (networkError || networkErrorSummary) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
|
||||
<StatusBoxes
|
||||
isLoading={isLoadingSummary}
|
||||
summary={summary}
|
||||
/>
|
||||
<OptionsHeader
|
||||
shouldRender={!isLoading}
|
||||
monitors={monitorLookup}
|
||||
selectedMonitor={selectedMonitor}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
<IncidentTable
|
||||
isLoading={isLoading}
|
||||
monitors={monitorLookup ? monitorLookup : {}}
|
||||
selectedMonitor={selectedMonitor}
|
||||
filter={filter}
|
||||
dateRange={dateRange}
|
||||
updateTrigger={updateTrigger}
|
||||
setUpdateTrigger={setUpdateTrigger}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checks;
|
||||
File renamed without changes.
@@ -0,0 +1,413 @@
|
||||
// Components
|
||||
import { Stack, Typography, Grid, Box, Button, Divider } from "@mui/material";
|
||||
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import SummaryCard from "../SummaryCard/index.jsx";
|
||||
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
|
||||
|
||||
// Utils
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
// Hooks
|
||||
import useFetchIncidents from "../../hooks/useFetchIncidents.js";
|
||||
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration.js";
|
||||
|
||||
const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { fetchIncidentById, resolveIncident } = useFetchIncidents();
|
||||
const [incidentData, setIncidentData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
const isActive = incidentData?.incident?.status === true;
|
||||
const duration = useGetIncidentsDuration(incidentData?.incident || null, isActive);
|
||||
|
||||
const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main;
|
||||
const toCapitalLetter = (text) =>
|
||||
typeof text === "string" && text.length > 0
|
||||
? text.charAt(0).toUpperCase() + text.slice(1)
|
||||
: text;
|
||||
|
||||
const KeyValueRow = ({ label, value, sx = {} }) => (
|
||||
<Grid
|
||||
container
|
||||
spacing={2}
|
||||
alignItems="flex-start"
|
||||
sx={{ ...sx }}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={4}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
textAlign="right"
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{value ?? "-"}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadIncident = async () => {
|
||||
if (!incidentId || !open) {
|
||||
setIncidentData(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const incidentData = await fetchIncidentById(incidentId);
|
||||
setIncidentData(incidentData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident:", error);
|
||||
setIncidentData(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadIncident();
|
||||
}, [incidentId, open, fetchIncidentById]);
|
||||
|
||||
const openResolveDialog = () => {
|
||||
setIsResolveDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeResolveDialog = () => {
|
||||
setIsResolveDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleAfterResolve = async () => {
|
||||
if (!incidentId) return;
|
||||
const updatedIncident = await fetchIncidentById(incidentId);
|
||||
if (updatedIncident) {
|
||||
setIncidentData(updatedIncident);
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<GenericFallback
|
||||
isLoading={true}
|
||||
message={t("incidentsPage.loadingIncidentDetails")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!incidentData) {
|
||||
return (
|
||||
<GenericFallback
|
||||
isLoading={false}
|
||||
message={t("incidentsPage.incidentNotFound")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
md={12}
|
||||
>
|
||||
<SummaryCard title={t("incidentsPage.overview")}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
gap={theme.spacing(2)}
|
||||
sx={{ mb: theme.spacing(3) }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
bgcolor: statusColor,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: statusColor,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4,
|
||||
}}
|
||||
>
|
||||
{isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="start"
|
||||
gap={theme.spacing(3)}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={2}
|
||||
sx={{ alignItems: "baseline" }}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
sx={{ minWidth: 70 }}
|
||||
>
|
||||
{t("incidentsPage.incidentItemMonitor")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
{incidentData?.monitor?.name || t("incidentsPage.unknownMonitor")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={2}
|
||||
sx={{ alignItems: "baseline" }}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
fontWeight={500}
|
||||
sx={{ minWidth: 70 }}
|
||||
>
|
||||
{t("incidentsPage.URL")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ flex: 1, wordBreak: "break-word", fontFamily: "monospace" }}
|
||||
>
|
||||
{incidentData?.monitor?.url || "-"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={12}
|
||||
>
|
||||
<SummaryCard title={t("incidentsPage.incidentAnalysis")}>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
alignItems="stretch"
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm={6}
|
||||
>
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
>
|
||||
{t("incidentsPage.timeline")}
|
||||
</Typography>
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.startedAt")}
|
||||
value={
|
||||
formatDateWithTz(
|
||||
incidentData?.incident?.startTime,
|
||||
"D MMM YYYY, h:mm A",
|
||||
uiTimezone
|
||||
) || "-"
|
||||
}
|
||||
sx={{ paddingBottom: theme.spacing(3) }}
|
||||
/>
|
||||
{!isActive && (
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.endedAt")}
|
||||
value={
|
||||
formatDateWithTz(
|
||||
incidentData?.incident?.endTime,
|
||||
"D MMM YYYY, h:mm A",
|
||||
uiTimezone
|
||||
) || "-"
|
||||
}
|
||||
sx={{ paddingBottom: theme.spacing(3) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.downtime")}
|
||||
value={duration || "-"}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sm="auto"
|
||||
sx={{ display: { xs: "none", sm: "flex" } }}
|
||||
>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
/>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
sm
|
||||
>
|
||||
<Stack gap={theme.spacing(2)}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ letterSpacing: 0.6, fontWeight: 700 }}
|
||||
>
|
||||
{isActive
|
||||
? t("incidentsPage.errorDetails")
|
||||
: t("incidentsPage.resolutionDetails")}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.statusCode")}
|
||||
value={incidentData?.incident?.statusCode ?? "-"}
|
||||
/>
|
||||
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.message")}
|
||||
value={incidentData?.incident?.message ?? "-"}
|
||||
/>
|
||||
|
||||
{!isActive && (
|
||||
<>
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.resolutionMethod")}
|
||||
value={
|
||||
toCapitalLetter(incidentData?.incident?.resolutionType) || "-"
|
||||
}
|
||||
/>
|
||||
{incidentData?.incident?.resolutionType === "manual" && (
|
||||
<KeyValueRow
|
||||
label={t("incidentsPage.comment")}
|
||||
value={
|
||||
incidentData?.incident.comment?.trim()
|
||||
? incidentData.incident.comment
|
||||
: t("incidentsPage.noCommentProvided")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SummaryCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericDialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
theme={theme}
|
||||
title={t("incidentsPage.incidentDetails")}
|
||||
width={{ xs: "95%", md: "45%", lg: "40%", xl: "35%" }}
|
||||
>
|
||||
<Stack
|
||||
gap={theme.spacing(6)}
|
||||
sx={{ maxHeight: "70vh", overflowY: "auto", pr: 1 }}
|
||||
>
|
||||
{renderContent()}
|
||||
{!isActive &&
|
||||
incidentData?.incident?.resolutionType === "manual" &&
|
||||
incidentData?.user?.email && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontStyle: "italic",
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.resolvedBy")}: {incidentData.user.email}
|
||||
</Typography>
|
||||
)}
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
gap={2}
|
||||
sx={{ pt: theme.spacing(3) }}
|
||||
>
|
||||
{isActive && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={openResolveDialog}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<ResolveIncidentDialog
|
||||
open={isResolveDialogOpen}
|
||||
incidentId={incidentId}
|
||||
onClose={closeResolveDialog}
|
||||
onResolve={resolveIncident}
|
||||
onAfterResolve={handleAfterResolve}
|
||||
onResolved={onResolved}
|
||||
/>
|
||||
</Stack>
|
||||
</GenericDialog>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentDetailsModal.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
incidentId: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onResolved: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentDetailsModal;
|
||||
@@ -0,0 +1,156 @@
|
||||
// Components
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Icon from "@/Components/v1/Icon/index.jsx";
|
||||
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
|
||||
|
||||
// Utils
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
const IncidentActionsMenu = ({ incident, monitor, onResolve, onOpenDetails }) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const openMenu = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const closeMenu = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const openResolveDialog = (e) => {
|
||||
closeMenu(e);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const isActive = incident.status === true;
|
||||
const handleGoToDetails = (e) => {
|
||||
e.stopPropagation();
|
||||
closeMenu(e);
|
||||
if (onOpenDetails) {
|
||||
onOpenDetails(incident.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoToMonitor = async (e) => {
|
||||
e.stopPropagation();
|
||||
closeMenu(e);
|
||||
try {
|
||||
if (monitor) {
|
||||
const path = TypeToPathMap[monitor.type];
|
||||
if (path && monitor.id) {
|
||||
navigate(`/${path}/${monitor.id}`);
|
||||
} else {
|
||||
console.error(`Monitor type not found`);
|
||||
}
|
||||
} else {
|
||||
console.error("Monitor information not found in incident data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching incident details:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="incident actions"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openMenu(event);
|
||||
}}
|
||||
sx={{
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
"& svg path": {
|
||||
stroke: theme.palette.primary.contrastTextTertiary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name="Settings"
|
||||
size={20}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
className="incident-actions-menu"
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={closeMenu}
|
||||
disableScrollLock
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
"& ul": {
|
||||
p: theme.spacing(2.5),
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
},
|
||||
"& li": { m: 0, color: theme.palette.primary.contrastTextSecondary },
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={handleGoToDetails}>
|
||||
{t("incidentsPage.incidentsTableActionDetails", "Details")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleGoToMonitor}>
|
||||
{t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")}
|
||||
</MenuItem>
|
||||
{isActive && (
|
||||
<MenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openResolveDialog(e);
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<ResolveIncidentDialog
|
||||
open={isDialogOpen}
|
||||
incidentId={incident.id}
|
||||
onClose={closeDialog}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentActionsMenu.propTypes = {
|
||||
incident: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
status: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
monitor: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
onResolve: PropTypes.func.isRequired,
|
||||
onOpenDetails: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentActionsMenu;
|
||||
@@ -1,225 +1,144 @@
|
||||
//Components
|
||||
import Stack from "@mui/material/Stack";
|
||||
import DataTable from "@/Components/v1/Table/index.jsx";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
|
||||
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
|
||||
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
|
||||
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
import IncidentActionsMenu from "./IncidentActionsMenu.jsx";
|
||||
|
||||
//Utils
|
||||
import { formatDateWithTz } from "@/Utils/timeUtils.js";
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useFetchChecksTeam,
|
||||
useFetchChecksByMonitor,
|
||||
useResolveIncident,
|
||||
} from "@/Hooks/checkHooks.js";
|
||||
import { Button, Typography, useTheme } from "@mui/material";
|
||||
import { lighten } from "@mui/material/styles";
|
||||
|
||||
const GetTooltip = (row) => {
|
||||
const theme = useTheme();
|
||||
const phases = row?.timings?.phases;
|
||||
|
||||
const phaseKeyFormattingMap = {
|
||||
firstByte: "first byte",
|
||||
};
|
||||
return (
|
||||
<Stack
|
||||
backgroundColor={lighten(theme.palette.primary.main, 0.1)}
|
||||
border={`1px solid ${theme.palette.primary.lowContrast}`}
|
||||
borderRadius={theme.shape.borderRadius}
|
||||
py={theme.spacing(2)}
|
||||
px={theme.spacing(4)}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>{`Status code: ${row?.statusCode}`}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>{`Response time: ${row?.responseTime} ms`}</Typography>
|
||||
{phases && (
|
||||
<>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastText}
|
||||
>{`Request timing: `}</Typography>
|
||||
<Table
|
||||
size="small"
|
||||
sx={{ ml: theme.spacing(2), mt: theme.spacing(2) }}
|
||||
>
|
||||
<TableBody>
|
||||
{Object.keys(phases)?.map((phaseKey) => (
|
||||
<TableRow key={phaseKey}>
|
||||
<TableCell sx={{ border: "none", p: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="success"
|
||||
>
|
||||
{`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell sx={{ border: "none", p: 0 }}>
|
||||
<Typography
|
||||
color={theme.palette.primary.contrastText}
|
||||
variant="body2"
|
||||
>{`${phases[phaseKey]} ms`}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
import { Typography, useTheme } from "@mui/material";
|
||||
|
||||
const IncidentTable = ({
|
||||
isLoading,
|
||||
monitors,
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
updateTrigger,
|
||||
setUpdateTrigger,
|
||||
monitors = [],
|
||||
incidents = [],
|
||||
incidentsCount = 0,
|
||||
isLoading = false,
|
||||
networkError = false,
|
||||
page = 0,
|
||||
rowsPerPage = 10,
|
||||
handleChangePage,
|
||||
handleChangeRowsPerPage,
|
||||
resolveIncident,
|
||||
handleUpdateTrigger,
|
||||
onOpenDetails,
|
||||
}) => {
|
||||
//Redux state
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
//Local state
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const selectedMonitorDetails = monitors?.[selectedMonitor];
|
||||
const selectedMonitorType = selectedMonitorDetails?.type;
|
||||
|
||||
//Hooks
|
||||
const [resolveIncident, resolveLoading] = useResolveIncident();
|
||||
|
||||
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
|
||||
useFetchChecksByMonitor({
|
||||
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
|
||||
type: selectedMonitorType,
|
||||
status: false,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter === "resolved" ? "all" : filter,
|
||||
ack: filter === "resolved" ? true : false,
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
enabled: selectedMonitor !== "0",
|
||||
updateTrigger,
|
||||
});
|
||||
|
||||
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
|
||||
useFetchChecksTeam({
|
||||
status: false,
|
||||
sortOrder: "desc",
|
||||
limit: null,
|
||||
dateRange,
|
||||
filter: filter === "resolved" ? "all" : filter,
|
||||
ack: filter === "resolved" ? true : false,
|
||||
page: page,
|
||||
rowsPerPage: rowsPerPage,
|
||||
enabled: selectedMonitor === "0",
|
||||
updateTrigger,
|
||||
});
|
||||
|
||||
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
|
||||
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
|
||||
isLoading = isLoadingTeam || isLoadingMonitor;
|
||||
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
//Handlers
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(event.target.value);
|
||||
};
|
||||
|
||||
const handleResolveIncident = (checkId) => {
|
||||
resolveIncident(checkId, setUpdateTrigger);
|
||||
const handleResolveIncident = async (incidentId, options = {}) => {
|
||||
try {
|
||||
await resolveIncident(incidentId, options);
|
||||
handleUpdateTrigger();
|
||||
} catch (error) {
|
||||
console.error(t("incidentsPage.errorResolvingIncident"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: t("incidentsTableMonitorName"),
|
||||
render: (row) => monitors[row.monitorId]?.name ?? "N/A",
|
||||
render: (row) => {
|
||||
const monitor = monitors.find((monitor) => monitor.id === row.monitorId);
|
||||
return monitor ? monitor.name : "N/A";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("incidentsTableStatus"),
|
||||
render: (row) => {
|
||||
const status = row.status === true ? "up" : "down";
|
||||
const status = row.status === true ? "down" : "up";
|
||||
const statusText =
|
||||
row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved");
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={status}
|
||||
customStyles={{ textTransform: "capitalize" }}
|
||||
text={statusText}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dateTime",
|
||||
content: t("incidentsTableDateTime"),
|
||||
id: "startTime",
|
||||
content: t("incidentsPage.startTime"),
|
||||
render: (row) => {
|
||||
const formattedDate = formatDateWithTz(
|
||||
row.createdAt,
|
||||
row.startTime || row.createdAt,
|
||||
"YYYY-MM-DD HH:mm:ss A",
|
||||
uiTimezone
|
||||
);
|
||||
return formattedDate;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endTime",
|
||||
content: t("incidentsPage.endTime"),
|
||||
render: (row) => {
|
||||
if (row.endTime) {
|
||||
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolutionType",
|
||||
content: t("incidentsPage.resolutionType"),
|
||||
render: (row) => {
|
||||
if (row.resolutionType) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.accent.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("incidentsTableStatusCode"),
|
||||
render: (row) => <HttpStatusLabel status={row.statusCode} />,
|
||||
},
|
||||
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
|
||||
{
|
||||
id: "message",
|
||||
content: t("incidentsTableMessage"),
|
||||
render: (row) => row.message || "-",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
content: t("actions"),
|
||||
render: (row) => {
|
||||
return row.ack === false ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={() => {
|
||||
handleResolveIncident(row._id);
|
||||
}}
|
||||
>
|
||||
{t("incidentsTableActionResolve")}
|
||||
</Button>
|
||||
) : (
|
||||
<Typography>
|
||||
{t("incidentsTableResolvedAt")}{" "}
|
||||
{formatDateWithTz(row.ackAt, "YYYY-MM-DD HH:mm:ss A", uiTimezone)}
|
||||
</Typography>
|
||||
return (
|
||||
<IncidentActionsMenu
|
||||
incident={row}
|
||||
monitor={monitors.find((monitor) => monitor.id === row.monitorId)}
|
||||
onResolve={handleResolveIncident}
|
||||
onOpenDetails={onOpenDetails}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading || resolveLoading) return <TableSkeleton />;
|
||||
if (isLoading) return <TableSkeleton />;
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
@@ -229,20 +148,25 @@ const IncidentTable = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && typeof checksCount === "undefined") {
|
||||
return <GenericFallback>{t("incidentsTableNoIncidents")}</GenericFallback>;
|
||||
if (!isLoading && !networkError && incidents?.length === 0) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
{t("incidentsTableNoIncidents", "No incidents found")}
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
const incidentsData = Array.isArray(incidents) ? incidents : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
headers={headers}
|
||||
data={checks}
|
||||
config={{ tooltipContent: GetTooltip }}
|
||||
data={incidentsData}
|
||||
/>
|
||||
<Pagination
|
||||
paginationLabel={t("incidentsTablePaginationLabel")}
|
||||
itemCount={checksCount}
|
||||
paginationLabel={t("incidentsTablePaginationLabel", "Incidents")}
|
||||
itemCount={incidentsCount || 0}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
@@ -253,12 +177,17 @@ const IncidentTable = ({
|
||||
};
|
||||
|
||||
IncidentTable.propTypes = {
|
||||
isLoading: PropTypes.bool,
|
||||
monitors: PropTypes.object,
|
||||
selectedMonitor: PropTypes.string,
|
||||
filter: PropTypes.string,
|
||||
dateRange: PropTypes.string,
|
||||
updateTrigger: PropTypes.bool,
|
||||
setUpdateTrigger: PropTypes.func,
|
||||
incidents: PropTypes.array.isRequired, // Array of incident objects
|
||||
incidentsCount: PropTypes.number.isRequired, // Total count for pagination
|
||||
isLoading: PropTypes.bool.isRequired, // Loading state
|
||||
networkError: PropTypes.bool, // Network error object
|
||||
page: PropTypes.number.isRequired, // Current page number
|
||||
rowsPerPage: PropTypes.number.isRequired, // Number of rows per page
|
||||
handleChangePage: PropTypes.func.isRequired, // Handler for page change
|
||||
handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change
|
||||
resolveIncident: PropTypes.func.isRequired,
|
||||
handleUpdateTrigger: PropTypes.func.isRequired,
|
||||
onOpenDetails: PropTypes.func,
|
||||
};
|
||||
|
||||
export default IncidentTable;
|
||||
File renamed without changes.
File renamed without changes.
+3
-26
@@ -2,39 +2,16 @@ import PropTypes from "prop-types";
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
|
||||
import Icon from "@/Components/v1/Icon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration";
|
||||
|
||||
const IncidentItem = ({ incident }) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isActive = incident.status === true;
|
||||
const calculateDuration = () => {
|
||||
if (!incident.startTime) {
|
||||
return "-";
|
||||
}
|
||||
const startTime = new Date(incident.startTime);
|
||||
const endTime = isActive
|
||||
? new Date()
|
||||
: incident.endTime
|
||||
? new Date(incident.endTime)
|
||||
: null;
|
||||
|
||||
if (!endTime) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
if (durationMs < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return getHumanReadableDuration(durationMs);
|
||||
};
|
||||
|
||||
const duration = calculateDuration();
|
||||
const duration = useGetIncidentsDuration(incident, isActive);
|
||||
const iconWrapperStyle = {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
+1
-1
@@ -84,7 +84,7 @@ const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null
|
||||
) : (
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
{incidents.map((incident, index) => (
|
||||
<Box key={incident._id}>
|
||||
<Box key={incident.id}>
|
||||
<IncidentItem incident={incident} />
|
||||
{index < incidents.length - 1 && <Divider sx={{ mt: theme.spacing(2) }} />}
|
||||
</Box>
|
||||
@@ -5,7 +5,6 @@ import PropTypes from "prop-types";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import SkeletonLayout from "./skeleton.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const OptionsHeader = ({
|
||||
@@ -15,27 +14,28 @@ const OptionsHeader = ({
|
||||
monitors,
|
||||
filter = "all",
|
||||
setFilter,
|
||||
dateRange = "hour",
|
||||
dateRange = "all",
|
||||
setDateRange,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
|
||||
|
||||
const filterOptions = [
|
||||
{ _id: "all", name: t("incidentsOptionsHeaderFilterAll") },
|
||||
{ _id: "down", name: t("incidentsOptionsHeaderFilterDown") },
|
||||
{ _id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") },
|
||||
{ _id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") },
|
||||
{ _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") },
|
||||
{ _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") },
|
||||
{ _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") },
|
||||
{ _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") },
|
||||
{ _id: "automatic", name: t("incidentsPage.incidentsOptionsHeaderFilterAutomatic") },
|
||||
];
|
||||
|
||||
// The stacks below which are three in number have the same style so
|
||||
const stackStyles = {
|
||||
direction: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(6),
|
||||
};
|
||||
|
||||
if (!shouldRender) return <SkeletonLayout />;
|
||||
if (!shouldRender) return;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Stack } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
|
||||
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
|
||||
|
||||
const ResolveIncidentDialog = ({
|
||||
open,
|
||||
incidentId,
|
||||
onClose,
|
||||
onResolve,
|
||||
onAfterResolve,
|
||||
onResolved,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [comment, setComment] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setComment("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!incidentId) return;
|
||||
const trimmed = comment.trim();
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await onResolve(incidentId, trimmed ? { comment: trimmed } : {});
|
||||
if (onAfterResolve) {
|
||||
await onAfterResolve();
|
||||
}
|
||||
onResolved?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error resolving incident:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GenericDialog
|
||||
open={open}
|
||||
theme={theme}
|
||||
title={t("incidentsPage.resolveIncidentDialogTitle")}
|
||||
description={t("incidentsPage.resolveIncidentDialogDescription")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Stack gap={theme.spacing(4)}>
|
||||
<TextInput
|
||||
label={t("incidentsPage.resolveIncidentDialogCommentLabel")}
|
||||
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
maxWidth="100%"
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={theme.spacing(4)}
|
||||
mt={theme.spacing(4)}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading || isSubmitting}
|
||||
>
|
||||
{t("incidentsPage.resolveIncidentDialogConfirm")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GenericDialog>
|
||||
);
|
||||
};
|
||||
|
||||
ResolveIncidentDialog.propTypes = {
|
||||
open: PropTypes.bool.isRequired,
|
||||
incidentId: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onResolve: PropTypes.func.isRequired,
|
||||
onAfterResolve: PropTypes.func,
|
||||
isLoading: PropTypes.bool,
|
||||
onResolved: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ResolveIncidentDialog;
|
||||
File renamed without changes.
File renamed without changes.
+12
-2
@@ -45,9 +45,8 @@ const useFetchIncidents = () => {
|
||||
setNetworkError(false);
|
||||
|
||||
const res = await networkService.getIncidentsByTeam(config);
|
||||
|
||||
setIncidents(res.data?.data?.incidents || []);
|
||||
setIncidentsCount(res.data?.data?.incidentsCount || 0);
|
||||
setIncidentsCount(res.data?.data?.count || 0);
|
||||
} catch (error) {
|
||||
setNetworkError(true);
|
||||
console.error(t("incidentsPage.errorFetchingIncidents"), error);
|
||||
@@ -73,6 +72,16 @@ const useFetchIncidents = () => {
|
||||
[fetchIncidentsByTeam]
|
||||
);
|
||||
|
||||
const fetchIncidentsByResolutionType = useCallback(
|
||||
async (resolutionType, config = {}) => {
|
||||
await fetchIncidentsByTeam({
|
||||
resolutionType,
|
||||
sortOrder: "desc",
|
||||
...config,
|
||||
});
|
||||
},
|
||||
[fetchIncidentsByTeam]
|
||||
);
|
||||
/**
|
||||
* Fetch resolved incidents
|
||||
*
|
||||
@@ -199,6 +208,7 @@ const useFetchIncidents = () => {
|
||||
fetchIncidentById,
|
||||
resolveIncident,
|
||||
fetchIncidentSummary,
|
||||
fetchIncidentsByResolutionType,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
|
||||
const useGetIncidentsDuration = (incident, isActive) => {
|
||||
if (!incident?.startTime) {
|
||||
return "-";
|
||||
}
|
||||
const startTime = new Date(incident?.startTime);
|
||||
const endTime = isActive
|
||||
? new Date()
|
||||
: incident?.endTime
|
||||
? new Date(incident.endTime)
|
||||
: null;
|
||||
|
||||
if (!endTime) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const durationMs = endTime.getTime() - startTime.getTime();
|
||||
|
||||
if (durationMs < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return getHumanReadableDuration(durationMs);
|
||||
};
|
||||
export default useGetIncidentsDuration;
|
||||
@@ -4,70 +4,110 @@ import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import IncidentTable from "./Components/IncidentTable/index.jsx";
|
||||
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
|
||||
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
|
||||
import { Box, Button } from "@mui/material";
|
||||
|
||||
import IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx";
|
||||
import IncidentDetailsModal from "./Components/IncidentDetailsModal/index.jsx";
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js";
|
||||
import {
|
||||
useFetchChecksSummaryByTeamId,
|
||||
useAcknowledgeChecks,
|
||||
} from "@/Hooks/checkHooks.js";
|
||||
import { useState, useEffect } from "react";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
//Constants
|
||||
const Checks = () => {
|
||||
// Redux state
|
||||
// Hooks
|
||||
import useFetchIncidents from "./hooks/useFetchIncidents.js";
|
||||
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js";
|
||||
|
||||
const Incidents2 = () => {
|
||||
const { monitorId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }];
|
||||
const BREADCRUMBS = [
|
||||
{ name: t("incidentsPageTitle", "Incidents"), path: "/incidents" },
|
||||
];
|
||||
|
||||
// Local state
|
||||
const [selectedMonitor, setSelectedMonitor] = useState("0");
|
||||
const [filter, setFilter] = useState(undefined);
|
||||
const [dateRange, setDateRange] = useState(undefined);
|
||||
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [monitorLookup, setMonitorLookup] = useState(undefined);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||
const [selectedIncidentId, setSelectedIncidentId] = useState(null);
|
||||
const handleUpdateTrigger = () => {
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
};
|
||||
|
||||
//Hooks
|
||||
const { acknowledge, isLoadingAcknowledge } = useAcknowledgeChecks();
|
||||
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
incidents,
|
||||
incidentsCount,
|
||||
isLoading: isLoadingIncidents,
|
||||
networkError: incidentsNetworkError,
|
||||
fetchIncidents,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
resolveIncident,
|
||||
fetchIncidentsByResolutionType,
|
||||
} = useFetchIncidents();
|
||||
|
||||
const networkError = monitorsNetworkError || incidentsNetworkError;
|
||||
|
||||
//Utils
|
||||
const theme = useTheme();
|
||||
const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({});
|
||||
const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({
|
||||
updateTrigger,
|
||||
});
|
||||
const { monitorId } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (monitorId) {
|
||||
setSelectedMonitor(monitorId);
|
||||
setPage(0);
|
||||
}, [selectedMonitor, filter, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = {
|
||||
monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined,
|
||||
sortOrder: "desc",
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
};
|
||||
|
||||
if (filter === "active") {
|
||||
fetchActiveIncidents(config);
|
||||
} else if (filter === "resolved") {
|
||||
fetchResolvedIncidents(config);
|
||||
} else if (filter === "manual") {
|
||||
fetchIncidentsByResolutionType("manual", config);
|
||||
} else if (filter === "automatic") {
|
||||
fetchIncidentsByResolutionType("automatic", config);
|
||||
} else {
|
||||
fetchIncidents(config);
|
||||
}
|
||||
}, [monitorId]);
|
||||
}, [
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
updateTrigger,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
fetchIncidents,
|
||||
fetchIncidentsByResolutionType,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const monitorLookup = monitors?.reduce((acc, monitor) => {
|
||||
acc[monitor._id] = {
|
||||
_id: monitor._id,
|
||||
const lookup = monitors?.reduce((acc, monitor) => {
|
||||
acc[monitor.id] = {
|
||||
id: monitor.id,
|
||||
name: monitor.name,
|
||||
type: monitor.type,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
setMonitorLookup(monitorLookup);
|
||||
setMonitorLookup(lookup);
|
||||
}, [monitors]);
|
||||
|
||||
const handleAcknowledge = () => {
|
||||
const monitorId = selectedMonitor === "0" ? null : selectedMonitor;
|
||||
acknowledge(setUpdateTrigger, monitorId);
|
||||
};
|
||||
|
||||
if (networkError || networkErrorSummary) {
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
@@ -75,27 +115,33 @@ const Checks = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
const handleOpenDetails = (id) => {
|
||||
if (!id) return;
|
||||
setSelectedIncidentId(id);
|
||||
setIsDetailsOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
setIsDetailsOpen(false);
|
||||
setSelectedIncidentId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
<Box alignSelf="flex-end">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
onClick={handleAcknowledge}
|
||||
disabled={isLoadingAcknowledge}
|
||||
>
|
||||
{selectedMonitor === "0"
|
||||
? t("checksPageActionResolveAllMonitor")
|
||||
: t("checksPageActionResolveMonitor")}
|
||||
</Button>
|
||||
</Box>
|
||||
<StatusBoxes
|
||||
isLoading={isLoadingSummary}
|
||||
summary={summary}
|
||||
/>
|
||||
|
||||
<IncidentsSummaryPanel updateTrigger={updateTrigger} />
|
||||
|
||||
<OptionsHeader
|
||||
shouldRender={!isLoading}
|
||||
shouldRender={!isLoadingMonitors}
|
||||
monitors={monitorLookup}
|
||||
selectedMonitor={selectedMonitor}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
@@ -104,17 +150,29 @@ const Checks = () => {
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
|
||||
<IncidentTable
|
||||
isLoading={isLoading}
|
||||
monitors={monitorLookup ? monitorLookup : {}}
|
||||
selectedMonitor={selectedMonitor}
|
||||
filter={filter}
|
||||
dateRange={dateRange}
|
||||
updateTrigger={updateTrigger}
|
||||
setUpdateTrigger={setUpdateTrigger}
|
||||
monitors={monitors || []}
|
||||
incidents={incidents || []}
|
||||
incidentsCount={incidentsCount || 0}
|
||||
isLoading={isLoadingIncidents}
|
||||
networkError={incidentsNetworkError}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
resolveIncident={resolveIncident}
|
||||
handleUpdateTrigger={handleUpdateTrigger}
|
||||
onOpenDetails={handleOpenDetails}
|
||||
/>
|
||||
<IncidentDetailsModal
|
||||
open={isDetailsOpen}
|
||||
incidentId={selectedIncidentId}
|
||||
onClose={handleCloseDetails}
|
||||
onResolved={handleUpdateTrigger}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checks;
|
||||
export default Incidents2;
|
||||
@@ -1,204 +0,0 @@
|
||||
//Components
|
||||
import DataTable from "@/Components/v1/Table/index.jsx";
|
||||
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
|
||||
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
|
||||
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
|
||||
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
|
||||
//Utils
|
||||
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
|
||||
import { useSelector } from "react-redux";
|
||||
import PropTypes from "prop-types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Typography, useTheme } from "@mui/material";
|
||||
|
||||
const IncidentTable = ({
|
||||
incidents = [],
|
||||
incidentsCount = 0,
|
||||
isLoading = false,
|
||||
networkError = false,
|
||||
page = 0,
|
||||
rowsPerPage = 10,
|
||||
handleChangePage,
|
||||
handleChangeRowsPerPage,
|
||||
resolveIncident,
|
||||
handleUpdateTrigger,
|
||||
}) => {
|
||||
const uiTimezone = useSelector((state) => state.ui.timezone);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const handleResolveIncident = async (incidentId) => {
|
||||
try {
|
||||
await resolveIncident(incidentId);
|
||||
handleUpdateTrigger();
|
||||
} catch (error) {
|
||||
console.error(t("incidentsPage.errorResolvingIncident"), error);
|
||||
}
|
||||
};
|
||||
|
||||
const headers = [
|
||||
{
|
||||
id: "monitorName",
|
||||
content: t("incidentsTableMonitorName"),
|
||||
render: (row) => row.monitorName ?? "N/A",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
content: t("incidentsTableStatus"),
|
||||
render: (row) => {
|
||||
const status = row.status === true ? "down" : "up";
|
||||
const statusText =
|
||||
row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved");
|
||||
return (
|
||||
<StatusLabel
|
||||
status={status}
|
||||
text={statusText}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "startTime",
|
||||
content: t("incidentsPage.startTime"),
|
||||
render: (row) => {
|
||||
const formattedDate = formatDateWithTz(
|
||||
row.startTime || row.createdAt,
|
||||
"YYYY-MM-DD HH:mm:ss A",
|
||||
uiTimezone
|
||||
);
|
||||
return formattedDate;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "endTime",
|
||||
content: t("incidentsPage.endTime"),
|
||||
render: (row) => {
|
||||
if (row.endTime) {
|
||||
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resolutionType",
|
||||
content: t("incidentsPage.resolutionType"),
|
||||
render: (row) => {
|
||||
if (row.resolutionType) {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
color:
|
||||
row.resolutionType === "manual"
|
||||
? theme.palette.accent.main
|
||||
: theme.palette.success.main,
|
||||
}}
|
||||
>
|
||||
{row.resolutionType}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "statusCode",
|
||||
content: t("incidentsTableStatusCode"),
|
||||
render: (row) => <HttpStatusLabel status={row.statusCode} />,
|
||||
},
|
||||
{
|
||||
id: "message",
|
||||
content: t("incidentsTableMessage"),
|
||||
render: (row) => row.message || "-",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
content: t("actions"),
|
||||
render: (row) => {
|
||||
if (row.status === true) {
|
||||
return (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="accent"
|
||||
sx={{
|
||||
minHeight: "max-content",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleResolveIncident(row._id);
|
||||
}}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableActionResolveManually")}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={theme.palette.primary.contrastTextSecondary}
|
||||
>
|
||||
{t("incidentsPage.incidentsTableResolved")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) return <TableSkeleton />;
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && !networkError && incidents?.length === 0) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
{t("incidentsTableNoIncidents", "No incidents found")}
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
const incidentsData = Array.isArray(incidents) ? incidents : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
headers={headers}
|
||||
data={incidentsData}
|
||||
/>
|
||||
<Pagination
|
||||
paginationLabel={t("incidentsTablePaginationLabel", "Incidents")}
|
||||
itemCount={incidentsCount || 0}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
IncidentTable.propTypes = {
|
||||
incidents: PropTypes.array.isRequired, // Array of incident objects
|
||||
incidentsCount: PropTypes.number.isRequired, // Total count for pagination
|
||||
isLoading: PropTypes.bool.isRequired, // Loading state
|
||||
networkError: PropTypes.bool, // Network error object
|
||||
page: PropTypes.number.isRequired, // Current page number
|
||||
rowsPerPage: PropTypes.number.isRequired, // Number of rows per page
|
||||
handleChangePage: PropTypes.func.isRequired, // Handler for page change
|
||||
handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change
|
||||
resolveIncident: PropTypes.func.isRequired,
|
||||
handleUpdateTrigger: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default IncidentTable;
|
||||
@@ -1,150 +0,0 @@
|
||||
// Components
|
||||
import { Stack } from "@mui/material";
|
||||
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
|
||||
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
|
||||
import IncidentTable from "./Components/IncidentTable/index.jsx";
|
||||
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
|
||||
import IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx";
|
||||
|
||||
//Utils
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Hooks
|
||||
import useFetchIncidents from "./hooks/useFetchIncidents.js";
|
||||
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js";
|
||||
|
||||
const Incidents2 = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const BREADCRUMBS = [
|
||||
{ name: t("incidentsPageTitle", "Incidents"), path: "/incidents" },
|
||||
];
|
||||
|
||||
const [selectedMonitor, setSelectedMonitor] = useState("0");
|
||||
const [filter, setFilter] = useState("all");
|
||||
const [dateRange, setDateRange] = useState("all");
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
||||
const [monitorLookup, setMonitorLookup] = useState(undefined);
|
||||
const [updateTrigger, setUpdateTrigger] = useState(false);
|
||||
const handleUpdateTrigger = () => {
|
||||
setUpdateTrigger((prev) => !prev);
|
||||
};
|
||||
|
||||
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
|
||||
{}
|
||||
);
|
||||
|
||||
const {
|
||||
incidents,
|
||||
incidentsCount,
|
||||
isLoading: isLoadingIncidents,
|
||||
networkError: incidentsNetworkError,
|
||||
fetchIncidents,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
resolveIncident,
|
||||
} = useFetchIncidents();
|
||||
|
||||
const networkError = monitorsNetworkError || incidentsNetworkError;
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [selectedMonitor, filter, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = {
|
||||
monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined,
|
||||
sortOrder: "desc",
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
};
|
||||
|
||||
if (filter === "active") {
|
||||
fetchActiveIncidents(config);
|
||||
} else if (filter === "resolved") {
|
||||
fetchResolvedIncidents(config);
|
||||
} else {
|
||||
fetchIncidents(config);
|
||||
}
|
||||
}, [
|
||||
selectedMonitor,
|
||||
filter,
|
||||
dateRange,
|
||||
page,
|
||||
rowsPerPage,
|
||||
updateTrigger,
|
||||
fetchActiveIncidents,
|
||||
fetchResolvedIncidents,
|
||||
fetchIncidents,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const lookup = monitors?.reduce((acc, monitor) => {
|
||||
acc[monitor.id] = {
|
||||
_id: monitor.id,
|
||||
name: monitor.name,
|
||||
type: monitor.type,
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
setMonitorLookup(lookup);
|
||||
}, [monitors]);
|
||||
|
||||
if (networkError) {
|
||||
return (
|
||||
<GenericFallback>
|
||||
<NetworkError />
|
||||
</GenericFallback>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChangePage = (_, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={theme.spacing(10)}>
|
||||
<Breadcrumbs list={BREADCRUMBS} />
|
||||
|
||||
<IncidentsSummaryPanel updateTrigger={updateTrigger} />
|
||||
|
||||
<OptionsHeader
|
||||
shouldRender={!isLoadingMonitors}
|
||||
monitors={monitorLookup}
|
||||
selectedMonitor={selectedMonitor}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
dateRange={dateRange}
|
||||
setDateRange={setDateRange}
|
||||
/>
|
||||
|
||||
<IncidentTable
|
||||
incidents={incidents || []}
|
||||
incidentsCount={incidentsCount || 0}
|
||||
isLoading={isLoadingIncidents}
|
||||
networkError={incidentsNetworkError}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
handleChangePage={handleChangePage}
|
||||
handleChangeRowsPerPage={handleChangeRowsPerPage}
|
||||
resolveIncident={resolveIncident}
|
||||
handleUpdateTrigger={handleUpdateTrigger}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Incidents2;
|
||||
Loaded 100 of 341 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user