Compare commits

..
Author SHA1 Message Date
gorkem-bwl d3947681a2 fix: Add missing translation keys for container monitoring
Added keys: containers, noContainersAvailable, noContainerSelected,
noContainerStatsAvailable, container, diskIO, networkIO, diskRead,
diskWrite, networkIn, networkOut
2026-01-16 20:18:38 -05:00
gorkem-bwl 01084108b0 fix: Include enableProxmoxContainers in monitor update payload
The useUpdateMonitor hook was missing enableProxmoxContainers from the
hardware-specific fields sent to the API, causing the setting to not
persist when editing a monitor.
2026-01-16 19:14:29 -05:00
gorkem-bwl e9da602af6 feat: Add Proxmox LXC container monitoring support
## Summary
Add support for monitoring Proxmox LXC containers through the Capture agent's
/api/v1/metrics/proxmox endpoint. This feature is opt-in via a new setting
in the infrastructure monitor configuration.

## Backend Changes
- Add enableProxmoxContainers field to Monitor schema and types
- Update networkService to conditionally fetch Proxmox metrics
- Handle HTTP 207 multi-status responses and merge errors
- Add containers field to Check schema for storing container data
- Update validation schemas for the new setting

## Frontend Changes
- Add ContainerStats component with charts and stat boxes
- Add Containers tab to infrastructure monitor details (conditional)
- Add enableProxmoxContainers switch in monitor create/edit form
- Fix chart colors for disk I/O and network I/O to be distinct
- Add dotColor2 prop to InfrastructureTooltip for multi-series charts
- Add translation keys to all 16 locale files

## How It Works
1. User enables "Proxmox container monitoring" in monitor settings
2. Checkmate appends /proxmox to the metrics URL when fetching
3. Container data is displayed in a dedicated tab with:
   - CPU, memory, disk, swap, and uptime stat boxes
   - Interactive charts for CPU, memory, disk I/O, network I/O
4. Errors from Capture are merged and logged appropriately
2026-01-16 19:07:49 -05:00
797 changed files with 49876 additions and 35674 deletions

No files matched your search

@@ -107,7 +107,6 @@ jobs:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:${{ steps.extract_tag.outputs.version }}
platforms: linux/amd64,linux/arm64
-1
View File
@@ -102,7 +102,6 @@ jobs:
context: .
file: ./docker/dist-arm/server.Dockerfile
push: true
no-cache: true
tags: |
ghcr.io/bluewave-labs/checkmate-backend-mono-multiarch:latest
platforms: linux/amd64,linux/arm64
-5
View File
@@ -3,8 +3,3 @@
.VSCodeCounter
*.sh
mongo
node_modules/
docs/architecture
docs/reviews
docs/todo
docs/frontend
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
echo "Running lint-staged..."
cd client && npx lint-staged && cd ..
cd server && npx lint-staged && cd ..
+5 -4
View File
@@ -42,7 +42,7 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
## Demo
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## User's guide
@@ -79,8 +79,9 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
- [Discord channel](https://discord.gg/NAb6H3UTjK)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -147,7 +148,7 @@ If you would like to sponsor an additional feature, [see this page](https://chec
## Contributing
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We are [Alex](http://github.com/ajhollid) (team lead), [Gorkem](http://github.com/gorkem-bwl/), [Owaise](http://github.com/Owaiseimdad), [Aryaman](https://github.com/Br0wnHammer), [Mert](https://github.com/mertssmnoglu) and [Karen](https://github.com/karenvicent) helping individuals and businesses monitor their infra and servers.
We pride ourselves on building strong connections with contributors at every level. Despite being a young project, Checkmate has already earned 7000+ stars and attracted 90+ contributors from around the globe.
+1 -1
View File
@@ -18,7 +18,7 @@
<div id="root"></div>
<script
type="module"
src="/src/main.tsx"
src="/src/main.jsx"
></script>
</body>
</html>
+1774 -1761
View File
File diff suppressed because it is too large. Load diff
+9 -17
View File
@@ -12,20 +12,16 @@
"format": "prettier --write .",
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "npm run format"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "7.0.0",
"@mui/material": "7.3.7",
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
"@mui/material": "6.4.11",
"@mui/x-charts": "^7.5.1",
"@mui/x-date-pickers": "7.29.0",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -34,25 +30,22 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.63.0",
"react-hook-form": "7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
"react-map-gl": "^8.1.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
"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"
"zod": "4.1.11"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -63,7 +56,6 @@
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.6",
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.3.6"
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { useSelector } from "react-redux";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import lightTheme from "./Utils/Theme/lightTheme";
import darkTheme from "./Utils/Theme/darkTheme";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Routes } from "./Routes";
import AppLayout from "@/Components/v1/Layouts/AppLayout";
function App() {
const mode = useSelector((state) => state.ui.mode);
// Cleanup
useEffect(() => {
return () => {
logger.cleanup();
networkService.cleanup();
};
}, []);
return (
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<CssBaseline />
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer />
</ThemeProvider>
);
}
export default App;
-56
View File
@@ -1,56 +0,0 @@
import { type CSSProperties } from "react";
import { useSelector } from "react-redux";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { SWRConfig } from "swr";
import { Routes } from "./Routes";
import AppLayout from "@/Components/layout/AppLayout";
import type { RootState } from "@/Types/state";
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
const swrConfig = {
dedupingInterval: 5000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
shouldRetryOnError: false,
errorRetryCount: 2,
errorRetryInterval: 1000,
focusThrottleInterval: 5000,
};
function App() {
const mode = useSelector((state: RootState) => state.ui.mode);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
<SWRConfig value={swrConfig}>
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.default,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer
newestOnTop={true}
theme={mode}
style={
{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
} as CSSProperties
}
/>
</ThemeProvider>
</SWRConfig>
);
}
export default App;
@@ -0,0 +1,53 @@
.wallet-adapter-dropdown {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: var(--env-var-spacing-1);
}
.wallet-adapter-button {
display: flex;
align-items: center;
justify-content: center;
width: auto;
height: var(--env-var-height-2) !important;
font-size: var(--env-var-font-size-medium-plus) !important;
font-weight: 500 !important;
margin: 0;
padding: calc(
(var(--env-var-height-2) - var(--env-var-font-size-medium-plus) * 1.2) / 2
)
var(--env-var-spacing-1);
white-space: nowrap;
}
.wallet-adapter-modal-title {
font-size: var(--font-size-h5) !important;
}
/* Separator styling */
.wallet-adapter-modal-divider {
background-color: var(--border-color) !important;
margin: var(--spacing-md) 0 !important;
}
/* Responsive fixes */
@media (max-width: 1200px) {
.wallet-adapter-button {
font-size: var(--env-var-font-size-medium) !important;
padding: calc((var(--env-var-height-2) - var(--env-var-font-size-medium) * 1.2) / 2)
var(--env-var-spacing-1-minus) !important;
}
}
@media (max-width: 900px) {
.wallet-adapter-modal-wrapper {
flex-direction: column !important;
}
.wallet-adapter-modal-divider {
margin: var(--spacing-sm) 0 !important;
}
}
@@ -0,0 +1,50 @@
// import { useMemo } from "react";
// import { ConnectionProvider, WalletProvider } from "@solana/wallet-adapter-react";
// import { WalletAdapterNetwork } from "@solana/wallet-adapter-base";
// import {
// UnsafeBurnerWalletAdapter,
// PhantomWalletAdapter,
// } from "@solana/wallet-adapter-wallets";
// import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
// import { clusterApiUrl } from "@solana/web3.js";
// import PropTypes from "prop-types";
// import "./index.css";
// // Default styles that can be overridden by your app
// import "@solana/wallet-adapter-react-ui/styles.css";
// export const Wallet = ({ children }) => {
// // The network can be set to 'devnet', 'testnet', or 'mainnet-beta'.
// const network = WalletAdapterNetwork.Mainnet;
// // You can also provide a custom RPC endpoint.
// const endpoint = useMemo(() => clusterApiUrl(network), [network]);
// const wallets = useMemo(
// () => [new PhantomWalletAdapter()],
// // eslint-disable-next-line react-hooks/exhaustive-deps
// [network]
// );
// return (
// <ConnectionProvider endpoint={endpoint}>
// <WalletProvider
// wallets={wallets}
// autoConnect
// >
// <WalletModalProvider>{children}</WalletModalProvider>
// </WalletProvider>
// </ConnectionProvider>
// );
// };
// Wallet.propTypes = {
// children: PropTypes.node,
// };
const Wallet = ({ children }) => {
return children;
};
export default Wallet;
@@ -1,56 +0,0 @@
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>
);
};
@@ -1,140 +0,0 @@
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/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>
);
};
@@ -1,76 +0,0 @@
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>
<Typography>
{t("common.labels.responseTime")}: {check.originalResponseTime.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>
);
};
@@ -1,155 +0,0 @@
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 type { ResponsiveStyleValue } from "@mui/system";
import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
height?: ResponsiveStyleValue<number | string>;
gap?: ResponsiveStyleValue<number | string>;
showStats?: boolean;
statsPosition?: "left" | "right";
}
interface ResponseTimeStats {
max: number | string;
avg: number | string;
}
const DEFAULT_HEIGHT = 50;
const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => {
if (!Array.isArray(checks) || checks.length === 0) {
return { max: "-", avg: "-" };
}
const validChecks = checks.filter((check) => check.originalResponseTime != null);
if (validChecks.length === 0) {
return { max: "-", avg: "-" };
}
const responseTimes = validChecks.map((check) => check.originalResponseTime);
const max = Math.max(...responseTimes);
const avg = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
return { max, avg };
};
export const HistogramResponseTime = ({
checks,
height = DEFAULT_HEIGHT,
gap,
showStats = true,
statsPosition = "right",
}: HistogramResponseTimeProps) => {
const theme = useTheme();
const { t } = useTranslation();
const stats = useMemo(() => calculateResponseTimeStats(checks), [checks]);
if (!Array.isArray(checks) || checks.length === 0) return null;
const data =
checks.length !== 25
? [...checks, ...Array(25 - checks.length).fill({ status: "placeholder" })]
: checks;
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
const statsContent = showStats &&
(typeof stats.max === "number" || typeof stats.avg === "number") && (
<Stack
justifyContent="center"
alignItems={statsPosition === "left" ? "flex-end" : "flex-start"}
minWidth={70}
pr={statsPosition === "left" ? theme.spacing(8) : 0}
pl={statsPosition === "right" ? theme.spacing(8) : 0}
>
<Typography variant="body2">
{t("common.charts.histogram.avg", { value: stats.avg })}
</Typography>
<Typography variant="body2">
{t("common.charts.histogram.max", { value: stats.max })}
</Typography>
</Stack>
);
return (
<Stack
direction={statsPosition === "left" ? "row-reverse" : "row"}
alignItems="center"
sx={{ width: "100%" }}
>
<Box sx={{ flex: 1 }}>
<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>
{statsContent}
</Stack>
);
};
@@ -1,47 +0,0 @@
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>
);
};
@@ -1,82 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { ToggleButtonGroup, ToggleButton } from "@/Components/inputs";
import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
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 { t } = useTranslation();
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: string | null
) => {
if (newValue !== null) {
setDateRange(newValue);
}
};
let timeFramePicker = null;
if (hasDateRange) {
timeFramePicker = (
<ToggleButtonGroup
orientation={isSmallScreen ? "vertical" : "horizontal"}
value={dateRange}
exclusive
onChange={handleChange}
size="small"
fullWidth={isSmallScreen}
>
<ToggleButton value="recent">
{t("components.headerTimeRange.labels.recent")}
</ToggleButton>
<ToggleButton value="day">
{t("components.headerTimeRange.labels.day")}
</ToggleButton>
<ToggleButton value="week">
{t("components.headerTimeRange.labels.week")}
</ToggleButton>
<ToggleButton value="month">
{t("components.headerTimeRange.labels.month")}
</ToggleButton>
</ToggleButtonGroup>
);
}
return (
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="flex-end"
alignItems="center"
gap={theme.spacing(4)}
>
<Box
width={16}
height={16}
>
{isLoading && <CircularProgress size={16} />}
</Box>
<Typography variant="body2">
{t(`components.headerTimeRange.description.${dateRange}`)}
</Typography>
{timeFramePicker}
</Stack>
);
};
-5
View File
@@ -1,5 +0,0 @@
export * from "./charts/HistogramResponseTime";
export * from "./charts/HeatmapResponseTime";
export * from "./charts/HeatmapResponseTimeTooltip";
export * from "./controls/HeaderCreate";
export * from "./controls/HeaderTimeRange";
@@ -1,48 +0,0 @@
import { Avatar as MuiAvatar } from "@mui/material";
import { useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { useTheme } from "@mui/material";
import type { RootState } from "@/Types/state";
interface AvatarProps {
src?: string;
small?: boolean;
sx?: object;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
}
export const Avatar = ({ src, small, sx, onClick = () => {} }: AvatarProps) => {
const { user } = useSelector((state: RootState) => state.auth);
const theme = useTheme();
if (!user) return null;
const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 };
const [image, setImage] = useState<string>();
useEffect(() => {
if (user.avatarImage) {
setImage(`data:image/png;base64,${user.avatarImage}`);
}
}, [user?.avatarImage]);
return (
<MuiAvatar
onClick={onClick}
alt={`${user?.firstName} ${user?.lastName}`}
src={src ? src : user?.avatarImage ? image : undefined}
sx={{
color: theme.palette.primary.contrastText,
fontSize: small ? "16px" : "22px",
fontWeight: 400,
backgroundColor: theme.palette.primary.main,
display: "inline-flex",
...style,
...sx,
}}
>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0) || ""}
</MuiAvatar>
);
};
@@ -1,19 +0,0 @@
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,
}}
/>
);
};
@@ -1,73 +0,0 @@
import { BaseBox } from ".";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { LAYOUT } from "@/Utils/Theme/constants";
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(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
}}
>
<Stack
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(LAYOUT.XS)}
>
{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>
);
};
@@ -1,265 +0,0 @@
import Logo from "@/assets/icons/checkmate-icon.svg?react";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
ErrorFallback,
EmptyFallback,
EmptyMonitorFallback,
BaseFallback,
} from "@/Components/design-elements/Fallback";
import { Breadcrumb } from "@/Components/design-elements/Breadcrumb";
import CircularProgress from "@mui/material/CircularProgress";
import { HeaderAuthControls } from "@/Pages/Auth/components/HeaderAuthControls";
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(LAYOUT.LG)}
{...props}
>
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
{children}
</Stack>
);
};
interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
totalCount: number;
bullets: string[] | unknown;
page: string;
actionButtonText: string;
actionLink: string;
children: React.ReactNode;
}
export const BasePageWithStates = ({
loading,
error,
totalCount,
page,
bullets,
actionButtonText,
actionLink,
children,
...props
}: BasePageWithStatesProps) => {
const showLoading = loading && totalCount === 0;
if (!loading && totalCount === 0) {
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;
totalCount: number;
page: string;
actionLink?: string;
children: React.ReactNode;
priorityFallback?: React.ReactNode;
}
export const MonitorBasePageWithStates = ({
loading,
error,
totalCount,
page,
actionLink,
children,
priorityFallback,
...props
}: MonitorBasePageWithStatesProps) => {
const { t } = useTranslation();
const showLoading = loading && totalCount === 0;
if (priorityFallback) {
return (
<BasePage
loading={loading}
error={error}
{...props}
>
<Stack height={"100%"}>{priorityFallback}</Stack>
</BasePage>
);
}
if (!loading && totalCount === 0) {
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>
);
};
interface BaseAuthPageProps extends React.PropsWithChildren, StackProps {
title: string;
subtitle: string;
}
export const BaseAuthPage = ({
title,
subtitle,
children,
...props
}: BaseAuthPageProps) => {
const theme = useTheme();
return (
<BasePage
breadcrumbOverride={[]}
gap={theme.spacing(LAYOUT.MD)}
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
position={"relative"}
{...props}
>
<HeaderAuthControls
hideLogo
py={theme.spacing(LAYOUT.XS)}
position={"absolute"}
top={0}
left={0}
/>
<Box width={{ xs: 60, sm: 70, md: 80 }}>
<Logo
width={"100%"}
height={"100%"}
/>
</Box>
<Stack alignItems={"center"}>
<Typography
variant="h1"
mb={theme.spacing(SPACING.LG)}
>
{title}
</Typography>
<Typography variant="h2">{subtitle}</Typography>
</Stack>
<Stack
gap={theme.spacing(LAYOUT.MD)}
width={{
xs: "80%",
md: "25%",
lg: "15%",
}}
>
{children}
</Stack>
</BasePage>
);
};
@@ -1,140 +0,0 @@
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";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
const isId = (segment: string): boolean => {
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
};
const actionSegments = ["create", "configure"];
const BreadcrumbWrapper = ({ children }: { children: ReactNode }) => {
const theme = useTheme();
return (
<MuiBreadcrumbs
separator={
<ChevronRight
size={16}
strokeWidth={1.5}
/>
}
sx={{
fontSize: "14px",
marginBottom: theme.spacing(6),
"& .MuiBreadcrumbs-separator": {
color: theme.palette.text.secondary,
},
}}
>
{children}
</MuiBreadcrumbs>
);
};
export const Breadcrumb = ({
breadcrumbOverride,
}: {
breadcrumbOverride?: string[] | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const location = useLocation();
// If override is an empty array, hide entirely
if (breadcrumbOverride !== undefined && breadcrumbOverride.length === 0) {
return null;
}
// If override has items, render them directly
if (breadcrumbOverride !== undefined && breadcrumbOverride.length > 0) {
return (
<BreadcrumbWrapper>
{breadcrumbOverride.map((item, index) => {
const isLast = index === breadcrumbOverride.length - 1;
return (
<Typography
key={index}
sx={{
fontSize: "14px",
fontWeight: isLast ? 600 : 400,
color: isLast ? theme.palette.primary.main : theme.palette.text.secondary,
}}
>
{item}
</Typography>
);
})}
</BreadcrumbWrapper>
);
}
// Default behavior: use location pathname
const segments = location.pathname.split("/").filter((x) => x);
if (segments.length === 0) {
return null;
}
// Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create"
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
const hasSubPage = isActionPage || isDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
};
return (
<BreadcrumbWrapper>
{hasSubPage ? (
<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>
)}
{hasSubPage && (
<Typography
sx={{
fontSize: "14px",
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
{getSubPageLabel()}
</Typography>
)}
</BreadcrumbWrapper>
);
};
@@ -1,48 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Check } from "lucide-react";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
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(LAYOUT.SM)}
alignItems="center"
>
<Check
size={16}
strokeWidth={1.5}
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
style={{
flexShrink: 0,
}}
/>
<Typography
component="span"
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
fontWeight={450}
sx={{
opacity: 0.9,
}}
>
{text}
</Typography>
</Stack>
);
};
@@ -1,23 +0,0 @@
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,
}}
/>
);
};
@@ -1,176 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
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(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
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(LAYOUT.XS)}
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(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
<Typography component="h1">{title}</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
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(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(SPACING.MD),
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>
);
};
@@ -1,155 +0,0 @@
import { BaseChart } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useMemo, useState, useEffect } from "react";
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
export const Gauge = ({
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
precision = 1,
unit = "%",
}: {
isLoading?: boolean;
progress?: number;
radius?: number;
strokeWidth?: number;
precision?: number;
unit?: string;
}) => {
const theme = useTheme();
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progress / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progress]
);
const [offset, setOffset] = useState(circumference);
useEffect(() => {
setOffset(circumference);
const timer = setTimeout(() => {
setOffset(circumference - strokeLength);
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
if (isLoading) {
return;
}
return (
<Box
display={"inline-block"}
position={"relative"}
width={radius}
height={radius}
bgcolor={theme.palette.background.paper}
borderRadius={"50%"}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
stroke={theme.palette.secondary.main}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
<circle
stroke={fillColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
style={{
transform: "rotate(-90deg)",
transformOrigin: "center",
transition: "stroke-dashoffset 1.5s ease-in-out",
}}
/>
</svg>
<Typography
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
};
export const DetailGauge = ({
title,
progress,
upperLabel,
upperValue,
lowerLabel,
lowerValue,
}: {
title: string;
progress: number;
upperLabel?: string;
upperValue?: string | number;
lowerLabel?: string;
lowerValue?: string | number;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={null}
title={title}
maxWidth={225}
>
<Stack
alignItems={"center"}
mb={theme.spacing(4)}
gap={theme.spacing(4)}
>
<Gauge progress={progress} />
</Stack>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography>{upperLabel}</Typography>
<Typography>{upperValue}</Typography>
</Stack>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography>{lowerLabel}</Typography>
<Typography>{lowerValue}</Typography>
</Stack>
</BaseChart>
);
};
@@ -1,19 +0,0 @@
import type { LucideIcon } from "lucide-react";
interface IconProps {
icon: LucideIcon;
size?: number;
strokeWidth?: number;
stroke?: string;
}
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
return (
<Icon
size={size}
strokeWidth={strokeWidth}
/>
);
};
export default Icon;
@@ -1,55 +0,0 @@
import type { Monitor } from "@/Types/Monitor";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { PulseDot, Dot } from "@/Components/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/Palette";
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}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
>
{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"}
>
{formatUrl(monitor?.url)}
</Typography>
{!isSmall && (
<>
<Dot />
<Typography>
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography>
</>
)}
</Stack>
</Stack>
);
};
@@ -1,63 +0,0 @@
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 { useTranslation } from "react-i18next";
import { WifiOff } from "lucide-react";
import { useState, useEffect } from "react";
interface OfflineBannerProps {
visible: boolean;
}
export const OfflineBanner = ({ visible }: OfflineBannerProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [shouldRender, setShouldRender] = useState(visible);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
if (visible) {
setShouldRender(true);
requestAnimationFrame(() => setIsAnimating(true));
} else {
setIsAnimating(false);
const timer = setTimeout(() => setShouldRender(false), 1000);
return () => clearTimeout(timer);
}
}, [visible]);
if (!shouldRender) return null;
return (
<Box
sx={{
position: "fixed",
top: isAnimating ? 0 : "-100%",
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
px: theme.spacing(8),
py: theme.spacing(4),
transition: "top 1s ease-in-out",
}}
>
<Stack
direction="row"
alignItems="center"
justifyContent="center"
gap={theme.spacing(4)}
>
<WifiOff size={20} />
<Typography
variant="body2"
fontWeight={500}
>
{t("components.offlineBanner.serverUnreachable")}
</Typography>
</Stack>
</Box>
);
};
@@ -1,61 +0,0 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTheme, keyframes } from "@mui/material";
const ripple = keyframes`
from {
opacity: 1;
transform: scale(0);
}
to {
opacity: 0;
transform: scale(2);
}
`;
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>
);
};
@@ -1,147 +0,0 @@
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>
);
};
@@ -1,79 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
export const SplitBox = ({
left,
right,
}: {
left: React.ReactNode;
right: React.ReactNode;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
direction={isSmall ? "column" : "row"}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.shape.borderRadius}
>
<Box
padding={theme.spacing(LAYOUT.XXL)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider}
flex={0.7}
sx={{
background:
theme.palette.mode === "dark"
? "linear-gradient(135deg, rgba(255, 255, 255, 0.01) 0%, rgba(255, 255, 255, 0.02) 100%)"
: "linear-gradient(135deg, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.02) 100%)",
}}
>
{left}
</Box>
<Box
flex={1}
padding={theme.spacing(LAYOUT.XXL)}
>
{right}
</Box>
</Stack>
);
};
export const ConfigBox = ({
title,
subtitle,
leftContent,
rightContent,
}: {
title: string;
subtitle: string;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
const theme = useTheme();
return (
<SplitBox
left={
<Stack spacing={theme.spacing(LAYOUT.XS)}>
<Typography
textTransform={"capitalize"}
component="h2"
variant="h2"
>
{title}
</Typography>
<Typography component="p">{subtitle}</Typography>
{leftContent}
</Stack>
}
right={rightContent}
/>
);
};
@@ -1,86 +0,0 @@
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/Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/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>
);
};
@@ -1,186 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/design-elements";
import { useTranslation } from "react-i18next";
import type { SxProps } from "@mui/material";
import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
sx?: SxProps;
}>;
export const BGBox = ({ children, sx }: StatusBoxProps) => {
const theme = useTheme();
return (
<BaseBox
sx={{
backgroundColor: theme.palette.background.default,
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(4),
...sx,
}}
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
sx={{
pointerEvents: "none",
backgroundImage: `
linear-gradient(${theme.palette.divider} 1px, transparent 1px),
linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px)
`,
backgroundSize: "24px 24px",
maskImage:
"linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)",
WebkitMaskImage:
"linear-gradient(135deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.4) 100%)",
}}
/>
{children}
</BaseBox>
);
};
const StatusBox = ({
label,
n,
color,
sx,
}: {
label: string;
n: number;
color: string | undefined;
sx?: SxProps;
}) => {
const theme = useTheme();
return (
<BGBox sx={sx}>
<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 MaintenanceStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.maintenance")}
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}
/>
);
};
export const BreachedStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.breached")}
n={n}
color={theme.palette.warning.main}
/>
);
};
export const TotalChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.total")}
n={n}
color={theme.palette.primary.light}
/>
);
};
export const DownChecksBox = ({ 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 UpChecksBox = ({ 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}
/>
);
};
@@ -1,95 +0,0 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/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, sx }: { status: MonitorStatus; sx?: SxProps }) => {
const { t } = useTranslation();
const theme = useTheme();
const palette = getStatusPalette(status);
const determineStatus = (status: MonitorStatus): string => {
if (status === "up") {
return t("pages.common.monitors.status.up");
} else if (status === "down") {
return t("pages.common.monitors.status.down");
} else if (status === "breached") {
return t("pages.common.monitors.status.breached");
} else if (status === "maintenance") {
return t("pages.common.monitors.status.maintenance");
} else if (status === "paused") {
return t("pages.common.monitors.status.paused");
} else if (status === "initializing") {
return t("pages.common.monitors.status.initializing");
}
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(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"
/>
<Typography>{transformedText}</Typography>
</BaseBox>
);
};
@@ -1,596 +0,0 @@
import Stack from "@mui/material/Stack";
import Grid2 from "@mui/material/Grid";
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, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import type { SxProps, Theme } 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;
getRowSx?: (row: T) => SxProps<Theme>;
};
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,
getRowSx,
}: 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(LAYOUT.XS)}>
{data.map((row) => {
const key = row.id || row._id || Math.random();
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(LAYOUT.XS),
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(LAYOUT.MD),
},
"& .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(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
fontSize: theme.typography.fontSize,
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`,
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",
...(getRowSx?.(row) as object),
}}
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;
itemsOnPage?: number;
}
export const Pagination = ({ ...props }: PaginationProps) => {
const { hasMore, itemsOnPage, ...rest } = props;
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
useEffect(() => {
if (
typeof itemsOnPage === "number" &&
itemsOnPage === 0 &&
rest.count > 0 &&
rest.page > 0 &&
rest.onPageChange
) {
rest.onPageChange(null, rest.page - 1);
}
}, [itemsOnPage, rest.count, rest.page, rest.onPageChange]);
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(SPACING.SM),
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(LAYOUT.XL),
px: theme.spacing(LAYOUT.XS),
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(SPACING.LG),
}}
>
<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>
);
};
@@ -1,64 +0,0 @@
import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
const theme = useTheme();
return (
<MuiTabs
sx={{
minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
height: 2,
bottom: 0,
},
"& .MuiTabs-flexContainer": {
gap: theme.spacing(16),
},
}}
{...props}
>
{props.children}
</MuiTabs>
);
};
import MuiTab from "@mui/material/Tab";
import type { TabProps } from "@mui/material/Tab";
interface CustomTabProps extends TabProps {}
export const Tab = (props: CustomTabProps) => {
const theme = useTheme();
return (
<MuiTab
disableRipple
iconPosition="start"
sx={{
textTransform: "none",
fontSize: 14,
fontWeight: 500,
minHeight: 34,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
alignItems: "flex-start",
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
},
"& .MuiTab-iconWrapper": {
marginRight: theme.spacing(2),
marginBottom: 0,
},
}}
{...props}
/>
);
};
@@ -1,43 +0,0 @@
import Typography from "@mui/material/Typography";
import Stack from "@mui/material/Stack";
import type { StackProps } from "@mui/material/Stack";
import Link from "@mui/material/Link";
import { Link as RouterLink } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
interface TextLinkProps extends StackProps {
text: string;
linkText: string;
href: string;
target?: "_self" | "_blank" | "_parent" | "_top";
}
export const TextLink = ({
text,
linkText,
href,
target = "_self",
...props
}: TextLinkProps) => {
const theme = useTheme();
return (
<Stack
direction="row"
gap={theme.spacing(4)}
{...props}
>
<Typography>{text}</Typography>
<Link
color="primary"
to={href}
component={RouterLink}
target={target}
{...(target === "_blank" && { rel: "noopener noreferrer" })}
>
{linkText}
</Link>
</Stack>
);
};
@@ -1,79 +0,0 @@
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>
);
};
@@ -1,23 +0,0 @@
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";
export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
export * from "./TextLink";
export * from "./OfflineBanner";
export * from "./Avatar";
@@ -1,88 +0,0 @@
import Autocomplete from "@mui/material/Autocomplete";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
import { TextField, Checkbox } from "@/Components/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;
};
-37
View File
@@ -1,37 +0,0 @@
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,
}}
/>
);
};
-56
View File
@@ -1,56 +0,0 @@
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,
}}
/>
);
}
);
@@ -1,48 +0,0 @@
import { MuiColorInput } from "mui-color-input";
import type { MuiColorInputProps } from "mui-color-input";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/inputs/FieldLabel";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
required?: boolean;
}
export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps) => {
const theme = useTheme();
const input = (
<MuiColorInput
{...props}
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;
};
-100
View File
@@ -1,100 +0,0 @@
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import type { DatePickerProps } from "@mui/x-date-pickers/DatePicker";
import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { FieldLabel } from "./FieldLabel";
import { Calendar } from "lucide-react";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
interface DatePickerComponentProps extends Omit<DatePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
error?: boolean;
helperText?: string;
}
export const DatePickerComponent = ({
fieldLabel,
required,
error,
helperText,
...props
}: DatePickerComponentProps) => {
const theme = useTheme();
const picker = (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
{...props}
slots={{
openPickerIcon: () => (
<Calendar
size={20}
stroke={theme.palette.text.secondary}
/>
),
...props.slots,
}}
slotProps={{
switchViewButton: { sx: { display: "none" } },
nextIconButton: { sx: { ml: theme.spacing(2) } },
day: {
sx: {
"&.MuiPickersDay-root.Mui-disabled": {
color: theme.palette.text.disabled,
},
},
},
field: {
sx: {
width: "fit-content",
"& > .MuiOutlinedInput-root": {
flexDirection: "row-reverse",
},
"& input": {
minHeight: 34,
p: 0,
pl: theme.spacing(3),
pr: theme.spacing(5),
},
"& fieldset": {
borderColor: error ? theme.palette.error.main : theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: error ? theme.palette.error.main : theme.palette.divider,
},
},
},
inputAdornment: { sx: { ml: 0 } },
openPickerButton: {
sx: {
py: 0,
mr: 0,
},
},
...props.slotProps,
}}
/>
</LocalizationProvider>
);
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{fieldLabel}</FieldLabel>}
{picker}
{helperText && (
<Typography
variant="caption"
color={error ? "error" : "text.secondary"}
>
{helperText}
</Typography>
)}
</Stack>
);
};
-78
View File
@@ -1,78 +0,0 @@
import Dialog from "@mui/material/Dialog";
import type { DialogProps } 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/inputs";
import { typographyLevels } from "@/Utils/Theme/Palette";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
export const DialogInput = ({
open,
title,
content,
onConfirm,
onCancel,
confirmColor = "primary",
cancelColor = "error",
loading = false,
cancelText,
confirmText,
children,
maxWidth,
fullWidth = false,
additionalButtons,
}: {
open: boolean;
title?: string;
content?: string;
onConfirm?(item: any): any;
onCancel?(item: any): any;
confirmColor?: "error" | "primary";
cancelColor?: "error" | "primary";
loading?: boolean;
cancelText?: string;
confirmText?: string;
children?: ReactNode;
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
additionalButtons?: ReactNode;
}) => {
const { t } = useTranslation();
return (
<Dialog
open={open}
maxWidth={maxWidth}
fullWidth={fullWidth}
>
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
<DialogContent>
{content && <DialogContentText>{content}</DialogContentText>}
{children}
</DialogContent>
<DialogActions>
<Button
loading={loading}
variant="contained"
color={cancelColor}
onClick={onCancel}
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
{additionalButtons}
{onConfirm && (
<Button
loading={loading}
variant="contained"
color={confirmColor}
onClick={onConfirm}
>
{confirmText ?? t("common.buttons.confirm")}
</Button>
)}
</DialogActions>
</Dialog>
);
};
@@ -1,40 +0,0 @@
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();
if (children === " ") {
children = "\u00A0";
}
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>
);
};
@@ -1,164 +0,0 @@
import { Box, Stack, Typography, IconButton } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { Upload, X } from "lucide-react";
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
interface FileObject {
src: string;
name: string;
file: File;
}
interface ImageUploadProps {
src?: string;
onChange?: (file: FileObject | undefined) => void;
maxSize?: number;
accept?: string[];
error?: string;
}
export const ImageUpload = ({
src,
onChange,
maxSize = 3 * 1024 * 1024,
accept = ["jpg", "jpeg", "png"],
error,
}: ImageUploadProps) => {
const theme = useTheme();
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [preview, setPreview] = useState<FileObject | null>(null);
const handleFile = useCallback(
(file: File | undefined) => {
if (!file) return;
const isValidType = accept.some((type) => file.type.includes(type));
const isValidSize = file.size <= maxSize;
if (!isValidType) {
setLocalError(t("components.imageUpload.errors.invalidFileFormat"));
return;
}
if (!isValidSize) {
setLocalError(t("components.imageUpload.errors.invalidFileSize"));
return;
}
setLocalError(null);
const fileObj: FileObject = {
src: URL.createObjectURL(file),
name: file.name,
file,
};
setPreview(fileObj);
onChange?.(fileObj);
},
[maxSize, accept, onChange, t]
);
const handleClear = () => {
setPreview(null);
setLocalError(null);
onChange?.(undefined);
if (inputRef.current) inputRef.current.value = "";
};
const displaySrc = src || preview?.src;
const displayError = localError || error;
return (
<Stack sx={{ width: "100%", maxWidth: 500 }}>
{displaySrc ? (
<Stack
alignItems="center"
gap={1}
>
<Box
component="img"
src={displaySrc}
alt="Preview"
sx={{ maxWidth: 250, maxHeight: 250, objectFit: "contain", borderRadius: 1 }}
/>
<IconButton
size="small"
onClick={handleClear}
sx={{ color: theme.palette.error.main }}
>
<X size={18} />
</IconButton>
</Stack>
) : (
<Stack
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
handleFile(e.dataTransfer.files?.[0]);
}}
alignItems="center"
justifyContent="center"
gap={1}
sx={{
position: "relative",
width: "100%",
minHeight: 150,
border: "2px dashed",
borderRadius: 1,
borderColor: isDragging ? theme.palette.primary.main : theme.palette.divider,
backgroundColor: isDragging ? theme.palette.action.hover : "transparent",
transition: "0.2s",
cursor: "pointer",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.action.hover,
},
}}
>
<input
ref={inputRef}
type="file"
accept={accept.map((ext) => `.${ext}`).join(",")}
onChange={(e) => handleFile(e.target.files?.[0])}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
<Upload size={24} />
<Typography
variant="body2"
color="text.secondary"
>
<Typography
component="span"
variant="body2"
color="primary"
fontWeight={500}
>
{t("components.imageUpload.clickToUpload")}
</Typography>{" "}
{t("components.imageUpload.orDragAndDrop")}
</Typography>
<Typography
variant="caption"
color="text.disabled"
>
{accept.join(", ").toUpperCase()} • {t("components.imageUpload.maxSize")}{" "}
{Math.round(maxSize / 1024 / 1024)}MB
</Typography>
</Stack>
)}
{displayError && (
<Typography
variant="caption"
color="error"
sx={{ mt: 1 }}
>
{displayError}
</Typography>
)}
</Stack>
);
};
@@ -1,100 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "@/Components/inputs";
import "flag-icons/css/flag-icons.min.css";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import { useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { setLanguage } from "@/Features/UI/uiSlice";
import type { RootState } from "@/Types/state";
const langMap: Record<string, string> = {
cs: "cz",
ja: "jp",
uk: "ua",
vi: "vn",
};
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const theme = useTheme();
const { language = "en" } = useSelector((state: RootState) => state.ui);
const dispatch = useDispatch();
const handleChange = (event: any) => {
const newLang = event.target.value;
dispatch(setLanguage(newLang));
};
const languages = Object.keys(i18n.options.resources || {});
return (
<Select
value={language}
onChange={handleChange}
size="small"
// sx={{
// minWidth: 80,
// "& .MuiSelect-select": {
// display: "flex",
// alignItems: "center",
// justifyContent: "center",
// },
// "& .MuiSelect-icon": {
// alignSelf: "center",
// },
// }}
>
{languages.map((lang) => {
let parsedLang = lang === "en" ? "gb" : lang;
if (parsedLang.includes("-")) {
parsedLang = parsedLang.split("-")[1].toLowerCase();
}
parsedLang = langMap[parsedLang] || parsedLang;
const flag = parsedLang ? `fi fi-${parsedLang}` : null;
return (
<MenuItem
key={lang}
value={lang}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Stack
direction="row"
spacing={theme.spacing(2)}
alignItems="center"
justifyContent="center"
>
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
}}
>
{flag && <span className={flag} />}
</Box>
<Box
component="span"
sx={{ textTransform: "uppercase" }}
>
{lang}
</Box>
</Stack>
</MenuItem>
);
})}
</Select>
);
};
export default LanguageSelector;
-88
View File
@@ -1,88 +0,0 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
import { Circle, CircleDot } from "lucide-react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput = ({ ...props }: RadioInputProps) => {
const theme = useTheme();
return (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
sx={{
padding: 0,
mt: theme.spacing(0.5),
color: theme.palette.text.secondary,
"&.Mui-checked": {
color: theme.palette.primary.main,
"& svg circle": {
fill: theme.palette.primary.main,
},
},
"& .MuiSvgIcon-root": {
fontSize: 16,
},
"& svg": {
stroke: "currentColor",
},
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
stroke: "currentColor",
fill: "none",
},
}}
/>
);
};
export const RadioWithDescription = ({
label,
description,
...props
}: RadioInputProps & { label: string; description: string }) => {
const theme = useTheme();
return (
<FormControlLabel
control={<RadioInput {...props} />}
label={
<>
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.text.secondary}
>
{description}
</Typography>
</>
}
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.background.paper,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
};
-110
View File
@@ -1,110 +0,0 @@
import Select from "@mui/material/Select";
import type { SelectProps } from "@mui/material/Select";
import React, { forwardRef } from "react";
import { useTheme } from "@mui/material/styles";
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;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
const renderValue = (selected: unknown) => {
const isMultiple = Boolean((props as { multiple?: boolean }).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>;
}
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}
ref={ref}
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-select": {
display: "flex",
alignItems: "center",
height: "100%",
boxSizing: "border-box",
},
"& .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;
};
export const SelectInput = forwardRef(SelectInputInner) as <T>(
props: SelectInputProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => React.ReactElement;
-92
View File
@@ -1,92 +0,0 @@
import Typography from "@mui/material/Typography";
import { forwardRef } from "react";
import Slider from "@mui/material/Slider";
import type { SliderProps } from "@mui/material/Slider";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import Box from "@mui/material/Box";
import type { ResponsiveStyleValue } from "@mui/system";
interface SliderInputProps extends SliderProps {
sx?: SliderProps["sx"];
showValue?: boolean;
}
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, ...props }, ref) => {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{props.value}</Typography>}
<Slider
{...props}
ref={ref}
sx={[
{
"& .MuiSlider-track": {
backgroundColor: theme.palette.primary.main,
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: theme.palette.grey[300],
opacity: 1,
},
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
"&:hover, &.Mui-focusVisible": {
boxShadow: "none",
},
"&:active": {
boxShadow: "none",
},
},
"& .MuiSlider-valueLabel": {
backgroundColor: theme.palette.primary.main,
},
},
...additionalSx,
]}
/>
</Stack>
);
}
);
interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
ref
) => {
const theme = useTheme();
const labelText = fieldLabel;
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{labelText}</FieldLabel>}
<Box maxWidth={sliderMaxWidth}>
<SliderInput
{...props}
showValue={showValue}
value={value}
ref={ref}
/>
</Box>
</Stack>
);
}
);
-47
View File
@@ -1,47 +0,0 @@
import { forwardRef } from "react";
import Switch from "@mui/material/Switch";
import type { SwitchProps } from "@mui/material/Switch";
import { useTheme } from "@mui/material/styles";
interface SwitchComponentProps extends SwitchProps {
dualOption?: boolean;
}
export const SwitchComponent = forwardRef<HTMLInputElement, SwitchComponentProps>(
function SwitchComponent({ sx, dualOption = false, ...props }, ref) {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Switch
{...props}
slotProps={{
input: {
ref: ref,
},
}}
sx={[
{
"& .MuiSwitch-switchBase": {
"&.Mui-checked": {
color: "#E0E0E0",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
},
},
},
...(dualOption && {
"& .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
},
}),
},
...additionalSx,
]}
/>
);
}
);
@@ -1,48 +0,0 @@
import IconButton from "@mui/material/IconButton";
import { Moon, Sun } from "lucide-react";
import { setMode } from "@/Features/UI/uiSlice.js";
import { useTheme } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
export const SwitchTheme = () => {
const mode = useSelector((state: RootState) => state.ui.mode);
const dispatch = useDispatch();
const theme = useTheme();
const handleChange = () => {
dispatch(setMode(mode === "light" ? "dark" : "light"));
};
return (
<IconButton
id="theme-toggle"
onClick={handleChange}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
"& svg": {
transition: "stroke 0.2s ease",
},
"&:hover svg path, &:hover svg line, &:hover svg polyline, &:hover svg rect, &:hover svg circle":
{
stroke: theme.palette.primary.main,
},
}}
>
{mode === "light" ? (
<Moon
size={16}
strokeWidth={1.5}
/>
) : (
<Sun
size={16}
strokeWidth={1.5}
/>
)}
</IconButton>
);
};
@@ -1,56 +0,0 @@
import { forwardRef } from "react";
import TextField from "@mui/material/TextField";
import type { TextFieldProps } from "@mui/material";
import { typographyLevels } from "@/Utils/Theme/Palette";
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";
@@ -1,61 +0,0 @@
import { MobileTimePicker } from "@mui/x-date-pickers/MobileTimePicker";
import type { MobileTimePickerProps } from "@mui/x-date-pickers/MobileTimePicker";
import type { Dayjs } from "dayjs";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { LocalizationProvider } from "@mui/x-date-pickers";
interface TimePickerComponentProps extends Omit<MobileTimePickerProps<Dayjs>, "label"> {
fieldLabel?: string;
required?: boolean;
}
export const TimePickerComponent = ({
fieldLabel,
required,
...props
}: TimePickerComponentProps) => {
const theme = useTheme();
const picker = (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<MobileTimePicker
{...props}
slotProps={{
field: {
sx: {
width: "fit-content",
"& input": {
minHeight: 34,
p: 0,
px: theme.spacing(5),
},
"& fieldset": {
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
"&:not(:has(.Mui-disabled)):not(:has(.Mui-error)) .MuiOutlinedInput-root:not(:has(input:focus)):hover fieldset":
{
borderColor: theme.palette.divider,
},
},
},
...props.slotProps,
}}
/>
</LocalizationProvider>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{picker}
</Stack>
);
}
return picker;
};
@@ -1,52 +0,0 @@
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,
}}
/>
);
};
-20
View File
@@ -1,20 +0,0 @@
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";
export * from "./Radio";
export * from "./Switch";
export * from "./Slider";
export * from "./ImageUpload";
export * from "./ColorPicker";
export { DatePickerComponent as DatePicker } from "./DatePicker";
export { TimePickerComponent as TimePicker } from "./TimePicker";
export * from "./LanguageSelector";
export * from "./SwitchTheme";
@@ -1,62 +0,0 @@
import Box from "@mui/material/Box";
import { useState, useEffect, useRef } from "react";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import BackgroundSVG from "@/assets/Images/background.svg";
import type { RootState } from "@/Types/state";
import { OfflineBanner } from "@/Components/design-elements";
import { setServerUnreachableCallback, get } from "@/Utils/ApiClient";
interface AppLayoutProps {
children: React.ReactNode;
}
const AppLayout = ({ children }: AppLayoutProps) => {
const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const [serverUnreachable, setServerUnreachable] = useState(false);
const retryIntervalRef = useRef<number | null>(null);
useEffect(() => {
setServerUnreachableCallback(setServerUnreachable);
}, []);
useEffect(() => {
if (serverUnreachable) {
retryIntervalRef.current = window.setInterval(async () => {
try {
await get("/health", { timeout: 5000 });
} catch {
// NO_OP
}
}, 5000);
} else if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current);
retryIntervalRef.current = null;
}
return () => {
if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current);
}
};
}, [serverUnreachable]);
return (
<Box
sx={{
minHeight: "100vh",
backgroundColor: theme.palette.background.default,
backgroundImage: mode === "dark" ? `url("${BackgroundSVG}")` : "none",
backgroundSize: "100% 100%",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<OfflineBanner visible={serverUnreachable} />
{children}
</Box>
);
};
export default AppLayout;
@@ -1,44 +0,0 @@
import { Sidebar } from "@/Components/sidebar";
import { Outlet } from "react-router";
import Stack from "@mui/material/Stack";
import { useMediaQuery } from "@mui/material";
import { useSidebar } from "@/Hooks/useSidebar";
import { useTheme } from "@mui/material";
const RootLayout = () => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const { collapsedWidth } = useSidebar();
return (
<Stack flexDirection="row">
<Sidebar />
<Stack
flex={1}
padding={6}
overflow={"hidden"}
sx={{
backgroundColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.01)"
: "rgba(0, 0, 0, 0.01)",
display: "flex",
alignItems: "center",
paddingLeft: isSmall ? `${collapsedWidth + 12}px` : 12,
}}
>
<Stack
maxWidth={1280}
width="100%"
paddingY={theme.spacing(6)}
flex={1}
>
<Outlet />
</Stack>
</Stack>
</Stack>
);
};
export default RootLayout;
@@ -1,107 +0,0 @@
import Stack from "@mui/material/Stack";
import { Select, Button } from "@/Components/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";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = {
http: "HTTP",
ping: "Ping",
port: "Port",
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
};
const statuses = ["up", "down"];
const states = ["active", "paused"];
export const ControlsFilter = ({
showTypes = true,
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
onClearFilters,
}: {
showTypes?: boolean;
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 { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
return (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
{showTypes && setSelectedTypes && (
<Select
multiple
placeholder="Type"
value={selectedTypes ?? []}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography>{typeDisplayNames[type] ?? 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}
>
{t("common.buttons.clearFilters")}
</Button>
)}
</Stack>
);
};
@@ -1,203 +0,0 @@
import { useRef, useEffect, useState } from "react";
import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
}
export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const mapRef = useRef<MapRef>(null);
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const mapPopupStyles = (
<GlobalStyles
styles={{
".maplibregl-popup-content": {
background: "transparent !important",
padding: "0 !important",
boxShadow: "none !important",
},
".maplibregl-popup-tip": {
display: "none",
},
".maplibregl-ctrl-attrib": {
display: "none",
},
".maplibregl-ctrl-logo": {
display: "none",
},
".maplibregl-popup-close-button": {
color: theme.palette.text.primary,
background: "transparent",
fontSize: "20px",
padding: "4px",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
}}
/>
);
useEffect(() => {
if (geoChecks.length === 0 || !mapRef.current) return;
const bounds = geoChecks.reduce(
(acc, check) => {
return {
minLng: Math.min(acc.minLng, check.location.longitude),
maxLng: Math.max(acc.maxLng, check.location.longitude),
minLat: Math.min(acc.minLat, check.location.latitude),
maxLat: Math.max(acc.maxLat, check.location.latitude),
};
},
{
minLng: Infinity,
maxLng: -Infinity,
minLat: Infinity,
maxLat: -Infinity,
}
);
if (bounds.minLng !== Infinity) {
mapRef.current.fitBounds(
[
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
],
{ padding: 50, duration: 1000 }
);
}
}, [geoChecks]);
const getMarkerColor = (status: boolean): string => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
<Box
height={500}
width={"100%"}
borderRadius={theme.shape.borderRadius}
overflow={"hidden"}
>
<Map
ref={mapRef}
initialViewState={{
longitude: 0,
latitude: 20,
zoom: 1.5,
}}
style={{ height: "100%", width: "100%" }}
mapStyle={
isDarkMode
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
}
>
{geoChecks.map((check, index) => (
<Marker
key={`${check.id}-${index}`}
longitude={check.location.longitude}
latitude={check.location.latitude}
anchor="bottom"
onClick={(e: any) => {
e.originalEvent.stopPropagation();
setSelectedCheck(check);
}}
>
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: getMarkerColor(check.status),
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
cursor: "pointer",
}}
/>
</Marker>
))}
{selectedCheck && (
<Popup
longitude={selectedCheck.location.longitude}
latitude={selectedCheck.location.latitude}
anchor="top"
onClose={() => setSelectedCheck(null)}
closeOnClick={false}
>
<Box
sx={{
minWidth: 200,
p: theme.spacing(4),
bgcolor: theme.palette.background.paper,
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: 1,
}}
>
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
>
{selectedCheck.location.city}, {selectedCheck.location.country}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status:
</Typography>{" "}
{selectedCheck.status ? "Up" : "Down"}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Response Time:
</Typography>{" "}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{new Date(selectedCheck.createdAt).toLocaleString()}
</Typography>
</Stack>
</Box>
</Popup>
)}
</Map>
</Box>
</>
);
};
@@ -1,54 +0,0 @@
import Stack from "@mui/material/Stack";
import { Tabs, Tab } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { GeoContinent } from "@/Types/GeoCheck";
interface HeaderGeoTabsProps {
geoCheckEnabled: boolean;
locations: GeoContinent[] | undefined;
selectedLocation: GeoContinent;
onLocationChange: (location: GeoContinent) => void;
}
export const HeaderGeoTabs = ({
geoCheckEnabled,
locations,
selectedLocation,
onLocationChange,
}: HeaderGeoTabsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!geoCheckEnabled || !locations || locations.length === 0) {
return null;
}
const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => {
onLocationChange(newValue);
};
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"flex-start"}
>
<Tabs
value={selectedLocation}
onChange={handleChange}
>
{locations.map((location) => (
<Tab
key={location}
label={t(
`pages.createMonitor.form.geoChecks.option.locations.options.${location}`
)}
value={location}
/>
))}
</Tabs>
</Stack>
);
};
@@ -1,176 +0,0 @@
import Stack from "@mui/material/Stack";
import { Icon, MonitorStatus } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { Settings, Pause, Play, Mail, Bug, Trash } 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 BaseHeaderProps {
monitor: Monitor;
}
const BaseHeader = ({ monitor, children }: React.PropsWithChildren<BaseHeaderProps>) => {
const theme = useTheme();
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"space-between"}
>
<MonitorStatus monitor={monitor} />
{children}
</Stack>
);
};
interface HeaderMonitorControlsProps {
path: string;
monitor?: Monitor | null;
isAdmin: boolean;
refetch: Function;
}
export const HeaderMonitorControls = ({
path,
monitor,
isAdmin,
refetch,
}: HeaderMonitorControlsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<BaseHeader 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("common.buttons.pause") : t("common.buttons.resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
{t("common.buttons.configure")}
</Button>
)}
</Stack>
</BaseHeader>
);
};
interface HeaderDeleteControlsProps {
monitor?: Monitor | null;
isAdmin: boolean;
refetch: Function;
onDelete?: () => void;
}
export const HeaderDeleteControls = ({
monitor,
isAdmin,
refetch,
onDelete,
}: HeaderDeleteControlsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<BaseHeader monitor={monitor}>
<Stack
width={{ xs: "100%", md: "auto" }}
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
{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("common.buttons.pause") : t("common.buttons.resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="error"
startIcon={<Icon icon={Trash} />}
onClick={() => {
onDelete?.();
}}
>
{t("common.buttons.delete")}
</Button>
)}
</Stack>
</BaseHeader>
);
};
@@ -1,35 +0,0 @@
import {
UpStatusBox,
DownStatusBox,
PausedStatusBox,
InitializingStatusBox,
BreachedStatusBox,
} from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import type { MonitorsSummary } from "@/Types/Monitor";
import { useTheme } from "@mui/material";
interface MonitorsSummaryProps {
summary: MonitorsSummary | null;
showBreached?: boolean;
}
export const HeaderMonitorsSummary = ({
summary,
showBreached = false,
}: MonitorsSummaryProps) => {
const theme = useTheme();
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<UpStatusBox n={summary?.upMonitors || 0} />
<DownStatusBox n={summary?.downMonitors || 0} />
{showBreached && <BreachedStatusBox n={summary?.breachedMonitors || 0} />}
<PausedStatusBox n={summary?.pausedMonitors || 0} />
<InitializingStatusBox n={summary?.initializingMonitors || 0} />
</Stack>
);
};
@@ -1,74 +0,0 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/design-elements";
import prettyMilliseconds from "pretty-ms";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
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 || 0;
const options = {
secondsDecimalDigits: 0,
millisecondsDecimalDigits: 0,
};
const streakTime = prettyMilliseconds(timeSinceLastFailure, options);
const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options);
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={prettyMilliseconds(monitorStats?.lastResponseTime ?? 0)}
/>
{monitor?.type === "http" && (
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
)}
</Stack>
);
};
@@ -1,163 +0,0 @@
import { BaseChart, BaseBox } from "@/Components/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>
);
};
@@ -1,206 +0,0 @@
import { BaseChart } from "@/Components/design-elements";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { Fragment, useId } from "react";
import { XTick } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
import type { HardwareCheckStats } from "@/Types/Monitor";
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
const createGradient = ({
id,
startColor,
endColor,
startOpacity = 0.8,
endOpacity = 0,
direction = "vertical",
}: {
id: string;
startColor: string;
endColor: string;
startOpacity?: number;
endOpacity?: number;
direction?: "vertical" | "horizontal";
}) => (
<defs>
<linearGradient
id={id}
x1={direction === "vertical" ? "0" : "0"}
y1={direction === "vertical" ? "0" : "0"}
x2={direction === "vertical" ? "0" : "1"}
y2={direction === "vertical" ? "1" : "0"}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={startOpacity}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={endOpacity}
/>
</linearGradient>
</defs>
);
export const HistogramInfrastructure = ({
dateRange,
title,
type,
idx: _idx,
checks,
xKey,
yDomain,
dataKeys,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
strokeColor,
fillColor,
yAxisFormatter,
}: {
dateRange: string;
title: string;
type: string;
idx: number | null;
checks: HardwareCheckStats[];
xKey: string;
yDomain?: number[];
dataKeys: string[];
gradient?: boolean;
gradientDirection?: "vertical" | "horizontal";
gradientStartColor?: string;
gradientEndColor?: string;
strokeColor: string;
fillColor?: string;
yAxisFormatter?: (value: number) => string;
}) => {
const theme = useTheme();
const uniqueId = useId();
const data = checks;
let avgTemps: { bucketDate: string; avg_temp: number | null }[] = [];
let tempYDomain: number[] = [];
if (type === "temp") {
avgTemps = data.map((check) => {
const temps = check.avgTemperature || [];
if (temps.length === 0) return { bucketDate: check.bucketDate, avg_temp: null };
const totalTemp = temps.reduce((sum, temp) => sum + (temp || 0), 0);
const avgTemp = totalTemp / temps.length;
return { bucketDate: check.bucketDate, avg_temp: avgTemp };
});
const maxTemp: number = avgTemps.reduce((max, item) => {
return item.avg_temp && item.avg_temp > max ? item.avg_temp : max;
}, 0);
tempYDomain = [0, Math.ceil((maxTemp * 1.3) / 10) * 10];
}
return (
<BaseChart
icon={null}
title={title}
>
<ResponsiveContainer
width="100%"
height={200}
>
<AreaChart data={type === "temp" ? avgTemps : data}>
<XAxis
dataKey={xKey}
tick={(props) => (
<XTick
{...props}
range={dateRange}
/>
)}
/>
<YAxis
domain={type === "temp" ? tempYDomain : yDomain}
tickFormatter={yAxisFormatter}
/>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
{dataKeys?.map((dataKey, index) => {
const gradientId = `gradient-${uniqueId}-${index}`;
return (
<Fragment key={`${dataKey}-${index}`}>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor || AREA_COLORS[index],
endColor: gradientEndColor || "transparent",
direction: gradientDirection,
})}
<Area
key={dataKey}
type="monotone"
dataKey={dataKey}
stroke={strokeColor || AREA_COLORS[index]}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
</Fragment>
);
})}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -1,106 +0,0 @@
import Stack from "@mui/material/Stack";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import { HistogramPageSpeedTooltip } from "@/Components/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>
);
};
@@ -1,144 +0,0 @@
import { BaseChart } from "@/Components/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/monitors";
import {
XAxis,
AreaChart,
Area,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
import type { PageSpeedGroupedCheck } 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: PageSpeedGroupedCheck[];
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={"bucketDate"}
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>
);
};
@@ -1,80 +0,0 @@
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 { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/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(SPACING.LG),
px: theme.spacing(LAYOUT.XS),
}}
>
<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(SPACING.XXL)}
mt={theme.spacing(SPACING.SM)}
>
<Box
width={theme.spacing(LAYOUT.XS)}
height={theme.spacing(LAYOUT.XS)}
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;
};
@@ -1,39 +0,0 @@
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>
);
};
@@ -1,166 +0,0 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "@/Utils/TimeUtils";
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>
);
};
@@ -1,161 +0,0 @@
import { BaseChart } from "@/Components/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>
);
};
@@ -1,105 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { BarChart3 } from "lucide-react";
import { BaseChart } from "@/Components/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>
);
};
@@ -1,91 +0,0 @@
import { BaseChart } from "@/Components/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>
);
};
-16
View File
@@ -1,16 +0,0 @@
export * from "./ControlsFilter";
export * from "./MonitorStatBoxes";
export * from "./HeaderMonitorControls";
export * from "./HeaderGeoTabs";
export * from "./GeoChecksMap";
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";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
@@ -1,41 +0,0 @@
import { Navigate } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import type { UserRole } from "@/Types/User";
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const authState = useSelector((state: RootState) => state.auth);
return authState.authToken ? (
children
) : (
<Navigate
to="/login"
replace
/>
);
};
interface RoleProtectedRouteProps {
roles: UserRole[];
children: React.ReactNode;
}
export const RoleProtectedRoute = ({ roles, children }: RoleProtectedRouteProps) => {
const authState = useSelector((state: RootState) => state.auth);
const userRoles = authState?.user?.role || [];
const canAccess = userRoles.some((role) => roles.includes(role));
return canAccess ? (
children
) : (
<Navigate
to="/uptime"
replace
/>
);
};
@@ -1,189 +0,0 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useNavigate } from "react-router";
import { MoreVertical, LogOut } from "lucide-react";
import { Avatar, Icon } from "@/Components/design-elements";
import { clearAuthState } from "@/Features/Auth/authSlice.js";
import type { RootState } from "@/Types/state.js";
interface AuthFooterProps {
collapsed: boolean;
accountMenuItems: Array<{
name: string;
path: string;
icon: React.ReactNode;
}>;
}
export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) => {
const { t } = useTranslation();
const theme = useTheme();
const user = useSelector((state: RootState) => state.auth.user);
const navigate = useNavigate();
const dispatch = useDispatch();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const menuOpen = Boolean(anchorEl);
const handleMenuOpen = (e: React.MouseEvent<HTMLElement>) =>
setAnchorEl(e.currentTarget);
const handleMenuClose = () => setAnchorEl(null);
const handleNavigate = (path: string) => {
handleMenuClose();
navigate(path);
};
const handleLogout = () => {
dispatch(clearAuthState());
navigate("/login");
};
const getRoleText = () => {
const role = user?.role ?? "";
if (role.includes("superadmin"))
return t("components.sidebar.authFooter.roles.superAdmin");
if (role.includes("admin")) return t("components.sidebar.authFooter.roles.admin");
if (role.includes("user")) return t("components.sidebar.authFooter.roles.user");
if (role.includes("demo")) return t("components.sidebar.authFooter.roles.demoUser");
return role;
};
const filteredMenuItems = accountMenuItems.filter((item) => {
if (!user) return false;
if (item.name === "Password" && user.role?.includes("demo")) return false;
if (item.name === "Team" && !user.role?.includes("superadmin")) return false;
return true;
});
const menuItemSx = {
gap: theme.spacing(LAYOUT.MD),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(LAYOUT.XS),
"& svg": { stroke: theme.palette.text.secondary },
};
return (
<Stack
direction="row"
alignItems="center"
py={theme.spacing(LAYOUT.XS)}
px={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(LAYOUT.MD)}
>
<Avatar
small
onClick={collapsed ? handleMenuOpen : undefined}
sx={{ cursor: collapsed ? "pointer" : "default" }}
/>
{!collapsed && (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.MD)}
minWidth={0}
flex={1}
>
<Stack
minWidth={0}
flex={1}
>
<Typography
fontWeight={500}
noWrap
sx={{ color: theme.palette.text.primary }}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
noWrap
sx={{ textTransform: "capitalize", color: theme.palette.text.secondary }}
>
{getRoleText()}
</Typography>
</Stack>
<IconButton
onClick={handleMenuOpen}
sx={{
"&:focus": { outline: "none" },
"& svg": { stroke: theme.palette.text.secondary },
}}
>
<Icon
icon={MoreVertical}
size={22}
/>
</IconButton>
</Stack>
)}
<Menu
anchorEl={anchorEl}
open={menuOpen}
onClose={handleMenuClose}
disableScrollLock
anchorOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: {
sx: {
mt: theme.spacing(-LAYOUT.XS),
ml: collapsed ? theme.spacing(SPACING.LG) : 0,
},
},
}}
MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }}
sx={{ ml: theme.spacing(LAYOUT.XS) }}
>
{collapsed && (
<Box
px={2}
pb={2}
sx={{ pointerEvents: "none" }}
>
<Typography
fontWeight={500}
fontSize={13}
>
{user?.firstName} {user?.lastName}
</Typography>
<Typography
fontSize={12}
sx={{ textTransform: "capitalize" }}
>
{getRoleText()}
</Typography>
</Box>
)}
{filteredMenuItems.map((item) => (
<MenuItem
key={item.name}
onClick={() => handleNavigate(item.path)}
sx={menuItemSx}
>
{item.icon}
{item.name}
</MenuItem>
))}
<MenuItem
onClick={handleLogout}
sx={menuItemSx}
>
<Icon
icon={LogOut}
size={20}
/>
{t("components.sidebar.authFooter.logOut")}
</MenuItem>
</Menu>
</Stack>
);
};
-120
View File
@@ -1,120 +0,0 @@
import { Icon } from "@/Components/design-elements";
import {
Globe,
Gauge,
Link,
Bell,
FileText,
AlertTriangle,
Wifi,
Wrench,
Database,
Settings,
HelpCircle,
MessageCircle,
Code,
User,
Lock,
Users,
} from "lucide-react";
export const getMenu = (t: Function) => {
return [
{
name: t("components.sidebar.menu.uptime"),
path: "uptime",
icon: <Icon icon={Globe} />,
},
{
name: t("components.sidebar.menu.pagespeed"),
path: "pagespeed",
icon: <Icon icon={Gauge} />,
},
{
name: t("components.sidebar.menu.infrastructure"),
path: "infrastructure",
icon: <Icon icon={Link} />,
},
{
name: t("components.sidebar.menu.notifications"),
path: "notifications",
icon: <Icon icon={Bell} />,
},
{
name: t("components.sidebar.menu.checks"),
path: "checks",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.menu.incidents"),
path: "incidents",
icon: <Icon icon={AlertTriangle} />,
},
{
name: t("components.sidebar.menu.statusPages"),
path: "status",
icon: <Icon icon={Wifi} />,
},
{
name: t("components.sidebar.menu.maintenance"),
path: "maintenance",
icon: <Icon icon={Wrench} />,
},
{
name: t("components.sidebar.menu.logs"),
path: "logs",
icon: <Icon icon={Database} />,
},
{
name: t("components.sidebar.menu.settings"),
icon: <Icon icon={Settings} />,
path: "settings",
},
];
};
export const getBottomMenu = (t: Function) => {
return [
{
name: t("components.sidebar.bottomMenu.support"),
path: "support",
icon: <Icon icon={HelpCircle} />,
},
{
name: t("components.sidebar.bottomMenu.discussions"),
path: "discussions",
icon: <Icon icon={MessageCircle} />,
},
{
name: t("components.sidebar.bottomMenu.docs"),
path: "docs",
icon: <Icon icon={FileText} />,
},
{
name: t("components.sidebar.bottomMenu.changelog"),
path: "changelog",
icon: <Icon icon={Code} />,
},
];
};
export const getAccountMenu = (t: Function) => {
return [
{
name: t("components.sidebar.accountMenu.profile"),
path: "account/profile",
icon: <Icon icon={User} />,
},
{
name: t("components.sidebar.accountMenu.password"),
path: "account/password",
icon: <Icon icon={Lock} />,
},
{
name: t("components.sidebar.accountMenu.team"),
path: "account/team",
icon: <Icon icon={Users} />,
},
];
};
-132
View File
@@ -1,132 +0,0 @@
import { useEffect } from "react";
import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
import { NavItem } from "@/Components/sidebar/NavItem";
import { StarPrompt } from "@/Components/sidebar/StarPrompt";
import { AuthFooter } from "@/Components/sidebar/Authfooter";
import { useNavigate, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useTheme, useMediaQuery } from "@mui/material";
import { useDispatch } from "react-redux";
import { setCollapsed } from "@/Features/UI/uiSlice";
const URL_MAP: Record<string, string> = {
support: "https://discord.com/invite/NAb6H3UTjK",
discussions: "https://github.com/bluewave-labs/checkmate/discussions",
docs: "https://bluewavelabs.gitbook.io/checkmate",
changelog: "https://github.com/bluewave-labs/checkmate/releases",
};
export const Sidebar = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const { width, transition, collapsed } = useSidebar();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const menu = getMenu(t);
const bottomMenu = getBottomMenu(t);
const accountMenu = getAccountMenu(t);
useEffect(() => {
dispatch(setCollapsed({ collapsed: isSmall }));
}, [isSmall, dispatch]);
const handleNavClick = (path: string) => {
const url = URL_MAP[path];
if (url) {
window.open(url, "_blank", "noreferrer");
} else {
navigate(`/${path}`);
}
if (isSmall) {
dispatch(setCollapsed({ collapsed: true }));
}
};
return (
<>
<Backdrop
open={!collapsed && isSmall}
onClick={() => dispatch(setCollapsed({ collapsed: true }))}
sx={{ zIndex: 999 }}
/>
<Stack
component="aside"
position={isSmall ? "fixed" : "sticky"}
top={0}
left={0}
minHeight={"100vh"}
maxHeight={"100vh"}
paddingTop={theme.spacing(LAYOUT.SM)}
paddingBottom={theme.spacing(LAYOUT.SM)}
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
sx={{
touchAction: "none",
transition: transition,
zIndex: 1000,
backdropFilter: "blur(8px)",
}}
>
<List
component="nav"
disablePadding
sx={{
px: theme.spacing(LAYOUT.SM),
flex: 1,
}}
>
<Logo
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</List>
<StarPrompt />
<List
component="nav"
disablePadding
sx={{ px: theme.spacing(LAYOUT.SM) }}
>
{bottomMenu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
return (
<NavItem
key={item.path}
item={item}
selected={selected}
onClick={() => handleNavClick(item.path)}
/>
);
})}
</List>
<Divider sx={{ borderColor: theme.palette.divider }} />
<AuthFooter
collapsed={collapsed}
accountMenuItems={accountMenu}
/>
</Stack>
</>
);
};
@@ -0,0 +1,229 @@
// Components
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Icon from "../Icon";
import Dialog from "../Dialog/index.jsx";
// Utils
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "../../../Utils/toastUtils.jsx";
import PropTypes from "prop-types";
import { usePauseMonitor, useDeleteMonitor } from "../../../Hooks/monitorHooks.js";
const ActionsMenu = ({
monitor,
isAdmin,
updateRowCallback,
pauseCallback,
setIsLoading = () => {},
}) => {
const [anchorEl, setAnchorEl] = useState(null);
const [actions, setActions] = useState({});
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
const [pauseMonitor, isPausing, error] = usePauseMonitor();
const [deleteMonitor, isDeleting] = useDeleteMonitor();
const handleRemove = async (event) => {
event.preventDefault();
event.stopPropagation();
let monitor = { id: actions.id };
console.log(monitor);
await deleteMonitor({ monitor });
updateRowCallback();
};
const handlePause = async () => {
try {
setIsLoading(true);
await pauseMonitor({ monitorId: monitor.id });
pauseCallback();
} catch (error) {
createToast({ body: "Failed to pause monitor." });
} finally {
setIsLoading(false);
}
};
const openMenu = (event, id, url) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
setActions({ id: id, url: url });
};
const openRemove = (e) => {
closeMenu(e);
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setAnchorEl(null);
};
const navigate = useNavigate();
return (
<>
<IconButton
aria-label="monitor actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event, monitor.id, monitor.type === "ping" ? null : monitor.url);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Icon
name="Settings"
size={20}
/>
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={(e) => closeMenu(e)}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": {
p: theme.spacing(2.5),
backgroundColor: theme.palette.primary.main,
},
"& li": { m: 0, color: theme.palette.primary.contrastTextSecondary },
/*
This should not be set automatically on the last of type
"& li:last-of-type": {
color: theme.palette.error.main,
}, */
},
},
}}
>
{actions.url !== null ? (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
window.open(actions.url, "_blank", "noreferrer");
}}
>
Open site
</MenuItem>
) : (
""
)}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/uptime/${actions.id}`);
}}
>
Details
</MenuItem>
{/* TODO - pass monitor id to Incidents page */}
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/incidents/${actions.id}`);
}}
>
Incidents
</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/uptime/configure/${actions.id}`);
}}
>
Configure
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
navigate(`/uptime/create/${actions.id}`);
}}
>
Clone
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
closeMenu(e);
e.stopPropagation();
handlePause(e);
}}
>
{monitor?.isActive === true ? "Pause" : "Resume"}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openRemove(e);
}}
sx={{ "&.MuiButtonBase-root": { color: theme.palette.error.main } }}
>
Remove
</MenuItem>
)}
</Menu>
<Dialog
open={isOpen}
theme={theme}
title="Do you really want to delete this monitor?"
description="Once deleted, this monitor cannot be retrieved."
/* Do we need stop propagation? */
onCancel={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
confirmationButtonLabel="Delete"
/* Do we need stop propagation? */
onConfirm={(e) => {
console.log(e);
e.stopPropagation();
handleRemove(e);
}}
isLoading={isDeleting}
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
/>
</>
);
};
ActionsMenu.propTypes = {
monitor: PropTypes.shape({
id: PropTypes.string,
url: PropTypes.string,
type: PropTypes.string,
isActive: PropTypes.bool,
}).isRequired,
isAdmin: PropTypes.bool,
updateRowCallback: PropTypes.func,
pauseCallback: PropTypes.func,
setIsLoading: PropTypes.func,
};
export default ActionsMenu;
+9
View File
@@ -0,0 +1,9 @@
.alert {
margin: 0;
width: fit-content;
}
.alert,
.alert button,
.alert .MuiTypography-root {
font-size: var(--env-var-font-size-medium);
}
+138
View File
@@ -0,0 +1,138 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Button, IconButton, Stack, Typography } from "@mui/material";
import Icon from "../Icon";
import "./index.css";
/**
* Icons mapping for different alert variants.
* @type {Object<string, JSX.Element>}
*/
const icons = {
info: (
<Icon
name="Info"
size={24}
/>
),
error: (
<Icon
name="AlertCircle"
size={24}
/>
),
warning: (
<Icon
name="AlertTriangle"
size={24}
/>
),
};
/**
* @param {Object} props
* @param {'info' | 'error' | 'warning'} props.variant - The type of alert.
* @param {string} [props.title] - The title of the alert.
* @param {string} [props.body] - The body text of the alert.
* @param {boolean} [props.isToast] - Indicates if the alert is used as a toast notification.
* @param {boolean} [props.hasIcon] - Whether to display an icon in the alert.
* @param {function} props.onClick - Toast dismiss function.
* @returns {JSX.Element}
*/
const Alert = ({ variant, title, body, isToast, hasIcon = true, onClick }) => {
const theme = useTheme();
/* TODO
Do we need other variants for alert?
*/
const text = theme.palette.secondary.contrastText;
const border = theme.palette.alert.contrastText;
const bg = theme.palette.alert.main;
const icon = icons[variant];
return (
<Stack
direction="row"
justifyContent="flex-start"
alignItems={hasIcon ? "" : "center"}
className="alert row-stack"
gap={theme.spacing(8)}
sx={{
padding: hasIcon ? theme.spacing(8) : `${theme.spacing(4)} ${theme.spacing(8)}`,
backgroundColor: bg,
border: `solid 1px ${border}`,
borderRadius: theme.shape.borderRadius,
}}
>
{hasIcon && <Box sx={{ color: text }}>{icon}</Box>}
<Stack
direction="column"
gap="2px"
sx={{ flex: 1 }}
>
{title && (
<Typography sx={{ fontWeight: "700", color: `${text}` }}>{title}</Typography>
)}
{body && (
<Typography sx={{ fontWeight: "400", color: `${text}` }}>{body}</Typography>
)}
{hasIcon && isToast && (
<Button
variant="text"
color="info"
onClick={onClick}
sx={{
fontWeight: "600",
width: "fit-content",
mt: theme.spacing(4),
padding: 0,
minWidth: 0,
}}
>
Dismiss
</Button>
)}
</Stack>
{isToast && (
<IconButton
onClick={onClick}
sx={{
alignSelf: "flex-start",
ml: "auto",
mr: "-5px",
mt: hasIcon ? "-5px" : 0,
padding: "5px",
"&:focus": {
outline: "none",
},
}}
>
<Icon
name="X"
size={20}
/>
</IconButton>
)}
</Stack>
);
};
Alert.propTypes = {
variant: PropTypes.oneOf(["info", "error", "warning"]).isRequired,
title: PropTypes.string,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
isToast: PropTypes.bool,
hasIcon: PropTypes.bool,
onClick: function (props, propName, componentName) {
if (props.isToast && !props[propName]) {
return new Error(
`Prop '${propName}' is required when 'isToast' is true in '${componentName}'.`
);
}
return null;
},
};
export default Alert;
@@ -0,0 +1,68 @@
import PropTypes from "prop-types";
import { Box, Stack, useTheme } from "@mui/material";
// import useUtils from "../../Pages/Uptime/utils";
/**
* A component that renders a pulsating dot with a specified color.
*
* @component
* @example
* // Example usage:
* <PulseDot color="#f00" />
*
* @param {Object} props
* @param {string} props.color - The color of the dot.
* @returns {JSX.Element} The PulseDot component.
*/
const PulseDot = ({ color }) => {
const theme = useTheme();
// const { statusToTheme } = useUtils();
/* TODO refactor so it gets status and gets theme color. Then uses theme.palette.[themeColor].lowContrast */
/* const themeColor = statusToTheme[status]; */
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.accent.contrastText,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
};
PulseDot.propTypes = {
color: PropTypes.string.isRequired,
};
export default PulseDot;
@@ -0,0 +1,36 @@
import Icon from "../Icon";
import PropTypes from "prop-types";
const ArrowLeft = ({ type, color = "#667085", ...props }) => {
if (type === "double") {
return (
<Icon
name="ChevronsLeft"
color={color}
{...props}
/>
);
} else if (type === "long") {
return (
<Icon
name="ArrowLeft"
color={color}
{...props}
/>
);
} else {
return (
<Icon
name="ChevronLeft"
color={color}
{...props}
/>
);
}
};
ArrowLeft.propTypes = {
color: PropTypes.string,
type: PropTypes.oneOf(["double", "long", "default"]),
};
export default ArrowLeft;
@@ -0,0 +1,29 @@
import Icon from "../Icon";
import PropTypes from "prop-types";
const ArrowRight = ({ type, color = "#667085", ...props }) => {
if (type === "double") {
return (
<Icon
name="ChevronsRight"
color={color}
{...props}
/>
);
} else {
return (
<Icon
name="ChevronRight"
color={color}
{...props}
/>
);
}
};
ArrowRight.propTypes = {
type: PropTypes.oneOf(["double", "default"]),
color: PropTypes.string,
};
export default ArrowRight;
Whitespace-only changes.
+73
View File
@@ -0,0 +1,73 @@
import { Avatar as MuiAvatar } from "@mui/material";
import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
/**
* @component
* @param {Object} props
* @param {string} props.src - Path to image for avatar
* @param {boolean} props.small - Specifies if avatar should be large
* @param {Object} [props.sx] - Additional styles to apply to the button.
* @returns {JSX.Element}
* @example
* // Render a red label
* <Avatar src="assets/img" first="Alex" last="Holliday" small />
*/
const Avatar = ({ src, small, sx, onClick = () => {} }) => {
const { user } = useSelector((state) => state.auth);
const theme = useTheme();
const style = small ? { width: 32, height: 32 } : { width: 64, height: 64 };
const border = small ? 1 : 3;
const [image, setImage] = useState();
useEffect(() => {
if (user.avatarImage) {
setImage(`data:image/png;base64,${user.avatarImage}`);
}
}, [user?.avatarImage]);
return (
<MuiAvatar
onClick={onClick}
alt={`${user?.firstName} ${user?.lastName}`}
/* TODO What is the /static/images/avatar/2.jpg ?*/
src={src ? src : user?.avatarImage ? image : "/static/images/avatar/2.jpg"}
sx={{
fontSize: small ? "16px" : "22px",
fontWeight: 400,
color: theme.palette.accent.contrastText,
backgroundColor: theme.palette.accent.main, // Same BG color as checkmate BG in sidebar
display: "inline-flex",
/*
TODO not sure what this is for*/
"&::before": {
content: `""`,
position: "absolute",
top: 0,
left: 0,
width: `100%`,
height: `100%`,
border: `${border}px solid rgba(255,255,255,0.2)`,
borderRadius: "50%",
},
...style,
...sx,
}}
>
{user.firstName?.charAt(0)}
{user.lastName?.charAt(0) || ""}
</MuiAvatar>
);
};
Avatar.propTypes = {
src: PropTypes.string,
small: PropTypes.bool,
sx: PropTypes.object,
onClick: PropTypes.func,
};
export default Avatar;
@@ -0,0 +1,22 @@
.MuiBreadcrumbs-root {
min-height: 34px;
}
.MuiBreadcrumbs-root svg {
width: 16px;
min-height: 16px;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li a {
font-size: var(--env-var-font-size-medium);
font-weight: 400;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:not(:last-child) {
cursor: pointer;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-li:last-child a {
font-weight: 500;
opacity: 1;
cursor: default;
}
.MuiBreadcrumbs-root .MuiBreadcrumbs-separator {
margin: 0;
}
@@ -0,0 +1,78 @@
import PropTypes from "prop-types";
import { Box, Breadcrumbs as MUIBreadcrumbs } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import ArrowRight from "../ArrowRight/index.jsx";
import "./index.css";
/**
* Breadcrumbs component that displays a list of breadcrumb items.
*
* @param {Object} props
* @param {Array} props.list - Array of breadcrumb items. Each item should have `name` and `path` properties.
* @param {string} props.list.name - The name to display for the breadcrumb.
* @param {string} props.list.path - The path to navigate to when the breadcrumb is clicked.
*
* @returns {JSX.Element} The rendered Breadcrumbs component.
*/
const Breadcrumbs = ({ list }) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<MUIBreadcrumbs
separator={<ArrowRight />}
aria-label="breadcrumb"
px={theme.spacing(2)}
py={theme.spacing(3.5)}
width="fit-content"
backgroundColor={theme.palette.secondary.main}
borderRadius={theme.shape.borderRadius}
lineHeight="18px"
sx={{
"& .MuiBreadcrumbs-li a": {
transition: "background-color 0.2s ease-in-out, color 0.2s ease-in-out",
},
"& .MuiBreadcrumbs-li:not(:last-of-type):hover a": {
backgroundColor: theme.palette.secondary.contrastText,
color: theme.palette.secondary.main,
},
}}
>
{list.map((item, index) => {
return (
<Box
component="a"
key={`${item.name}-${index}`}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.shape.borderRadius}
onClick={() => navigate(item.path)}
sx={{
opacity: 0.8,
textTransform: "capitalize",
"&, &:hover": {
color: theme.palette.secondary.contrastText,
},
}}
>
{item.name}
</Box>
);
})}
</MUIBreadcrumbs>
);
};
Breadcrumbs.propTypes = {
list: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}).isRequired
).isRequired,
};
export default Breadcrumbs;
@@ -0,0 +1,30 @@
import React from "react";
import Button from "@mui/material/Button";
import { styled } from "@mui/material/styles";
const RoundGradientButton = styled(Button)(({ theme }) => ({
position: "relative",
border: "5px solid transparent",
backgroundClip: "padding-box",
borderRadius: 30,
fontSize: "1.2rem",
color: theme.palette.primary.contrastText,
backgroundColor: theme.palette.background.main,
"&:after": {
position: "absolute",
top: -3,
left: -3,
right: -3,
bottom: -3,
background:
theme.palette.mode === "dark"
? "linear-gradient(90deg, #842bd2, #ff5451, #8c52ff)"
: "linear-gradient(90deg, #842bd2, #ff5451, #8c52ff)",
content: '""',
zIndex: -1,
borderRadius: 30,
},
}));
export default RoundGradientButton;
@@ -0,0 +1,214 @@
/**
* CustomAreaChart component for rendering an area chart with optional gradient and custom ticks.
*
* @param {Object} props - The properties object.
* @param {Array} props.data - The data array for the chart.
* @param {Array} props.dataKeys - An array of data keys to be plotted as separate areas.
* @param {string} props.xKey - The key for the x-axis data.
* @param {string} [props.yKey] - The key for the y-axis data (optional).
* @param {Object} [props.xTick] - Custom tick component for the x-axis.
* @param {Object} [props.yTick] - Custom tick component for the y-axis.
* @param {string} [props.strokeColor] - The base stroke color for the areas.
* If not provided, uses a predefined color palette.
* @param {string} [props.fillColor] - The base fill color for the areas.
* @param {boolean} [props.gradient=false] - Whether to apply a gradient fill to the areas.
* @param {string} [props.gradientDirection="vertical"] - The direction of the gradient.
* @param {string} [props.gradientStartColor] - The start color of the gradient.
* Defaults to the area's stroke color if not provided.
* @param {string} [props.gradientEndColor] - The end color of the gradient.
* @param {Object} [props.customTooltip] - Custom tooltip component for the chart.
* @param {string|number} [props.height="100%"] - Height of the chart container.
*
* @returns {JSX.Element} The rendered area chart component.
*
* @example
* // Single series chart
* <CustomAreaChart
* data={temperatureData}
* dataKeys={["temperature"]}
* xKey="date"
* yKey="temperature"
* gradient={true}
* gradientStartColor="#ff6b6b"
* gradientEndColor="#4ecdc4"
* />
*
* @example
* // Multi-series chart with custom tooltip
* <CustomAreaChart
* data={performanceData}
* dataKeys={["cpu.usage", "memory.usage"]}
* xKey="timestamp"
* xTick={<CustomTimeTick />}
* yTick={<PercentageTick />}
* gradient={true}
* customTooltip={({ active, payload, label }) => (
* <CustomTooltip
* label={label}
* payload={payload}
* active={active}
* />
* )}
* />
*/
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { createGradient } from "../Utils/gradientUtils.jsx";
import PropTypes from "prop-types";
import { useTheme } from "@mui/material";
import { useId } from "react";
import { Fragment } from "react";
const CustomAreaChart = ({
data,
dataKeys,
xKey,
xDomain,
yKey,
yDomain,
xTick,
yTick,
strokeColor,
fillColor,
gradient = false,
gradientDirection = "vertical",
gradientStartColor,
gradientEndColor,
customTooltip,
height = "100%",
}) => {
const theme = useTheme();
const uniqueId = useId();
const AREA_COLORS = [
// Blues
"#3182bd", // Deep blue
"#6baed6", // Medium blue
"#9ecae1", // Light blue
// Greens
"#74c476", // Soft green
"#a1d99b", // Light green
"#c7e9c0", // Pale green
// Oranges
"#fdae6b", // Warm orange
"#fdd0a2", // Light orange
"#feedde", // Pale orange
// Purples
"#9467bd", // Lavender
"#a55194", // Deep magenta
"#c994c7", // Soft magenta
// Reds
"#ff9896", // Soft red
"#de2d26", // Deep red
"#fc9272", // Medium red
// Cyans/Teals
"#17becf", // Cyan
"#7fcdbb", // Teal
"#a1dab4", // Light teal
// Yellows
"#fec44f", // Mustard
"#fee391", // Light yellow
"#ffffd4", // Pale yellow
// Additional colors
"#e377c2", // Soft pink
"#bcbd22", // Olive
"#2ca02c", // Vibrant green
];
return (
<ResponsiveContainer
width="100%"
height={height}
// FE team HELP! Why does this overflow if set to 100%?
>
<AreaChart data={data}>
<XAxis
dataKey={xKey}
{...(xDomain && { domain: xDomain })}
{...(xTick && { tick: xTick })}
/>
<YAxis
dataKey={yKey}
{...(yDomain && { domain: yDomain })}
{...(yTick && { tick: yTick })}
/>
<CartesianGrid
stroke={theme.palette.primary.lowContrast}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
{dataKeys.map((dataKey, index) => {
const gradientId = `gradient-${uniqueId}-${index}`;
return (
<Fragment key={dataKey}>
{gradient === true &&
createGradient({
id: gradientId,
startColor: gradientStartColor || AREA_COLORS[index],
endColor: gradientEndColor,
direction: gradientDirection,
})}
<Area
yKey={dataKey}
key={dataKey}
type="monotone"
dataKey={dataKey}
stroke={strokeColor || AREA_COLORS[index]}
fill={gradient === true ? `url(#${gradientId})` : fillColor}
/>
</Fragment>
);
})}
{customTooltip ? (
<Tooltip
cursor={{ stroke: theme.palette.primary.lowContrast }}
content={customTooltip}
wrapperStyle={{ pointerEvents: "none" }}
/>
) : (
<Tooltip />
)}
</AreaChart>
</ResponsiveContainer>
);
};
CustomAreaChart.propTypes = {
data: PropTypes.array.isRequired,
dataKeys: PropTypes.array.isRequired,
xTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
yTick: PropTypes.object, // Recharts takes an instance of component, so we can't pass the component itself
xKey: PropTypes.string.isRequired,
xDomain: PropTypes.array,
yKey: PropTypes.string,
yDomain: PropTypes.array,
fillColor: PropTypes.string,
strokeColor: PropTypes.string,
gradient: PropTypes.bool,
gradientDirection: PropTypes.string,
gradientStartColor: PropTypes.string,
gradientEndColor: PropTypes.string,
customTooltip: PropTypes.object,
height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default CustomAreaChart;
Whitespace-only changes.
@@ -0,0 +1,181 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { useEffect, useState } from "react";
import "./index.css";
import { useSelector } from "react-redux";
/* TODO add prop validation and jsdocs */
const BarChart = ({ checks = [] }) => {
const theme = useTheme();
const [animate, setAnimate] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
useEffect(() => {
setAnimate(true);
});
// set responseTime to average if there's only one check
if (checks.length === 1) {
checks[0] = { ...checks[0], responseTime: 50 };
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill("placeholder");
checks = [...checks, ...placeholders];
}
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="50px"
width="fit-content"
onClick={(event) => event.stopPropagation()}
sx={{
cursor: "default",
}}
>
{checks.map((check, index) =>
check === "placeholder" ? (
/* TODO what is the purpose of this box? */
// CAIO_REVIEW the purpose of this box is to make sure there are always at least 25 bars
// even if there are less than 25 checks
<Box
key={`${check}-${index}`}
position="relative"
width={theme.spacing(4.5)}
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
}}
/>
) : (
<Tooltip
title={
<>
<Typography>
{formatDateWithTz(
check.updatedAt,
"ddd, MMMM D, YYYY, HH:mm A",
uiTimezone
)}
</Typography>
<Box mt={theme.spacing(2)}>
<Box
display="inline-block"
width={theme.spacing(4)}
height={theme.spacing(4)}
backgroundColor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{ borderRadius: "50%" }}
/>
<Stack
display="inline-flex"
direction="row"
justifyContent="space-between"
ml={theme.spacing(2)}
gap={theme.spacing(12)}
>
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
Response Time
</Typography>
<Typography component="span">
{check.originalResponseTime}
<Typography
component="span"
sx={{ opacity: 0.8 }}
>
{" "}
ms
</Typography>
</Typography>
</Stack>
</Box>
</>
}
placement="top"
key={`check-${check?.id}`}
slotProps={{
popper: {
className: "bar-tooltip",
modifiers: [
{
name: "offset",
options: {
offset: [0, -10],
},
},
],
sx: {
"& .MuiTooltip-tooltip": {
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.primary.lowContrast,
borderRadius: theme.shape.borderRadius,
boxShadow: theme.shape.boxShadow,
px: theme.spacing(4),
py: theme.spacing(3),
},
"& .MuiTooltip-tooltip p": {
/* TODO Font size should point to theme */
fontSize: 12,
color: theme.palette.secondary.contrastText,
fontWeight: 500,
},
"& .MuiTooltip-tooltip span": {
/* TODO Font size should point to theme */
fontSize: 11,
color: theme.palette.secondary.contrastText,
fontWeight: 600,
},
},
},
}}
>
<Box
position="relative"
width="9px"
height="100%"
backgroundColor={theme.palette.primary.lowContrast}
sx={{
borderRadius: theme.spacing(1.5),
/*
TODO this doesn't seem to be used
"&:hover > .MuiBox-root": {
filter: "brightness(0.8)",
}, */
}}
>
<Box
position="absolute"
bottom={0}
width="100%"
height={`${animate ? check.responseTime : 0}%`}
backgroundColor={
check.status
? theme.palette.success.lowContrast
: theme.palette.error.lowContrast
}
sx={{
borderRadius: theme.spacing(1.5),
transition: "height 600ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
</Tooltip>
)
)}
</Stack>
);
};
export default BarChart;
@@ -0,0 +1,106 @@
// Components
import { Typography, Stack } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import IconBox from "../../IconBox/index.jsx";
/**
* `EmptyView` is a functional React component that displays an empty state view with an optional icon, header, and message.
*
* @component
* @param {Object} props - The properties that define the `EmptyView` component.
* @param {React.ReactNode} [props.icon] - An optional icon to display at the top of the empty view.
* @param {string} [props.header] - An optional header text displayed next to the icon.
* @param {string} [props.message="No Data"] - The message to be displayed in the empty view.
* @param {'h1' | 'h2' | 'h3'} [props.headingLevel="h2"] - The heading level for the message text.
* @param {string} [props.justifyContent="flex-start"] - The CSS `justify-content` value to align elements vertically.
* @param {string} [props.height="100%"] - The height of the empty view container.
*
* @example
* // Example usage of EmptyView component:
* <EmptyView
* icon={<SomeIcon />}
* header="Average Response Time"
* message="No Response Time Available"
* headingLevel="h2"
* justifyContent="center"
* height="50%"
* />
*
* @returns {React.Element} The `EmptyView` component with customizable icon, header, and message.
*/
const EmptyView = ({
icon,
header,
message = "No Data",
headingLevel = "h2",
justifyContent = "flex-start",
height = "100%",
}) => {
const theme = useTheme();
return (
<Stack
flex={1}
direction="row"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: 2,
borderTopRightRadius: 4,
borderBottomRightRadius: 4,
}}
>
<Stack
flex={1}
alignItems="center"
sx={{
padding: theme.spacing(8),
justifyContent,
gap: theme.spacing(8),
height,
"& h2": {
color: theme.palette.primary.contrastTextSecondary,
fontSize: 15,
fontWeight: 500,
},
"& tspan, & text": {
fill: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Stack
alignSelf="flex-start"
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
{icon && <IconBox>{icon}</IconBox>}
{header && <Typography component="h2">{header}</Typography>}
</Stack>
<Stack
flex={1}
justifyContent="center"
alignItems="center"
>
<Typography component={headingLevel}>{message}</Typography>
</Stack>
</Stack>
</Stack>
);
};
EmptyView.propTypes = {
message: PropTypes.string,
icon: PropTypes.node,
header: PropTypes.string,
headingLevel: PropTypes.oneOf(["h1", "h2", "h3"]),
justifyContent: PropTypes.string,
height: PropTypes.string,
};
export default EmptyView;
@@ -0,0 +1,104 @@
import { Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import IconBox from "../../IconBox/index.jsx";
import EmptyView from "./EmptyView.jsx";
import PropTypes from "prop-types";
const ChartBox = ({
children,
icon,
header,
height = "300px",
justifyContent = "space-between",
Legend,
borderRadiusRight = 4,
sx,
noDataMessage,
isEmpty = false,
}) => {
const theme = useTheme();
if (isEmpty) {
return (
<EmptyView
icon={icon}
header={header}
message={noDataMessage}
/>
);
}
return (
<Stack
flex={1}
direction="row"
sx={{
backgroundColor: theme.palette.primary.main,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.primary.lowContrast,
borderRadius: 2,
borderTopRightRadius: borderRadiusRight,
borderBottomRightRadius: borderRadiusRight,
}}
>
<Stack
flex={1}
sx={{
padding: theme.spacing(8),
justifyContent,
gap: theme.spacing(8),
height,
minWidth: 250,
"& h2": {
color: theme.palette.primary.contrastTextSecondary,
fontSize: 15,
fontWeight: 500,
},
"& .MuiBox-root:not(.area-tooltip) p": {
color: theme.palette.primary.contrastTextTertiary,
fontSize: 13,
},
"& .MuiBox-root > span": {
color: theme.palette.primary.contrastText,
fontSize: 20,
"& span": {
opacity: 0.8,
marginLeft: 2,
fontSize: 15,
},
},
"& tspan, & text": {
fill: theme.palette.primary.contrastTextTertiary,
},
"& path": {
transition: "fill 300ms ease, stroke-width 400ms ease",
},
}}
>
<Stack
alignSelf="flex-start"
direction="row"
alignItems="center"
gap={theme.spacing(6)}
>
{icon && <IconBox>{icon}</IconBox>}
{header && <Typography component="h2">{header}</Typography>}
</Stack>
{children}
</Stack>
{Legend && Legend}
</Stack>
);
};
export default ChartBox;
ChartBox.propTypes = {
children: PropTypes.node,
icon: PropTypes.node,
header: PropTypes.string,
height: PropTypes.string,
noDataMessage: PropTypes.string,
isEmpty: PropTypes.bool,
};
@@ -0,0 +1,14 @@
.radial-chart {
position: relative;
display: inline-block;
}
.radial-chart-base {
opacity: 0.3;
}
.radial-chart-progress {
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 1.5s ease-in-out;
}
Loaded 100 of 797 files, more files were not shown because too many files have changed in this diff. Show more