diff --git a/Client/src/Components/Charts/ChartBox/index.jsx b/Client/src/Components/Charts/ChartBox/index.jsx
index eed3efd0d..bd27d4775 100644
--- a/Client/src/Components/Charts/ChartBox/index.jsx
+++ b/Client/src/Components/Charts/ChartBox/index.jsx
@@ -11,6 +11,7 @@ const ChartBox = ({
justifyContent = "space-between",
Legend,
borderRadiusRight = 4,
+ sx,
}) => {
const theme = useTheme();
return (
@@ -70,8 +71,8 @@ const ChartBox = ({
alignItems="center"
gap={theme.spacing(6)}
>
- {icon}
- {header}
+ {icon && {icon}}
+ {header && {header}}
{children}
@@ -84,7 +85,7 @@ export default ChartBox;
ChartBox.propTypes = {
children: PropTypes.node,
- icon: PropTypes.node.isRequired,
- header: PropTypes.string.isRequired,
+ icon: PropTypes.node,
+ header: PropTypes.string,
height: PropTypes.string,
};
diff --git a/Client/src/Components/Image/index.jsx b/Client/src/Components/Image/index.jsx
index 778675f92..9304a4385 100644
--- a/Client/src/Components/Image/index.jsx
+++ b/Client/src/Components/Image/index.jsx
@@ -14,6 +14,7 @@ const Image = ({
maxWidth = "auto",
maxHeight = "auto",
base64,
+ sx,
}) => {
if (shouldRender === false) {
return null;
@@ -36,6 +37,7 @@ const Image = ({
maxHeight={maxHeight}
width={width}
height={height}
+ sx={{ ...sx }}
/>
);
};
@@ -49,6 +51,7 @@ Image.propTypes = {
maxWidth: PropTypes.string,
maxHeight: PropTypes.string,
base64: PropTypes.string,
+ sx: PropTypes.object,
};
export default Image;
diff --git a/Client/src/Components/StatBox/index.jsx b/Client/src/Components/StatBox/index.jsx
index 4b1fd2454..58fdb7ebf 100644
--- a/Client/src/Components/StatBox/index.jsx
+++ b/Client/src/Components/StatBox/index.jsx
@@ -1,4 +1,5 @@
-import { Box, Typography } from "@mui/material";
+import { Stack, Typography } from "@mui/material";
+import Image from "../Image";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
@@ -29,7 +30,15 @@ import useUtils from "../../Pages/Uptime/Monitors/Hooks/useUtils";
* @returns {React.ReactElement} A styled box containing the statistic
*/
-const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) => {
+const StatBox = ({
+ img,
+ alt,
+ heading,
+ subHeading,
+ gradient = false,
+ status = "",
+ sx,
+}) => {
const theme = useTheme();
const { statusToTheme } = useUtils();
const themeColor = statusToTheme[status];
@@ -70,7 +79,8 @@ const StatBox = ({ heading, subHeading, gradient = false, status = "", sx }) =>
};
return (
-
...sx,
}}
>
- {heading}
- {subHeading}
-
+ {img && (
+
+ )}
+
+ {heading}
+ {subHeading}
+
+
);
};
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx
new file mode 100644
index 000000000..22ef5596d
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx
@@ -0,0 +1,71 @@
+import { Stack, Typography, List, ListItem } from "@mui/material";
+import { useTheme } from "@emotion/react";
+import PulseDot from "../../../../../Components/Animated/PulseDot";
+import "/node_modules/flag-icons/css/flag-icons.min.css";
+
+const BASE_BOX_PADDING_VERTICAL = 16;
+const BASE_BOX_PADDING_HORIZONTAL = 8;
+
+const DeviceTicker = ({ data, width = "100%", connectionStatus }) => {
+ const theme = useTheme();
+ const statusColor = {
+ up: theme.palette.success.main,
+ down: theme.palette.error.main,
+ };
+
+ return (
+
+
+
+
+
+ {connectionStatus === "up" ? "Connected" : "No connection"}
+
+
+
+ {data.slice(Math.max(data.length - 5, 0)).map((dataPoint) => {
+ const countryCode = dataPoint?.countryCode?.toLowerCase() ?? null;
+ const flag = countryCode ? `fi fi-${countryCode}` : null;
+ return (
+
+
+
+ {flag && }
+ {dataPoint?.city || "Unknown"}
+
+ {`Response time: ${Math.floor(dataPoint?.responseTime ?? 0)} ms`}
+ {`UPT burned: ${dataPoint.uptBurnt}`}
+ {`${dataPoint?.device?.manufacturer} ${dataPoint?.device?.model}`}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default DeviceTicker;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json
new file mode 100644
index 000000000..d8ee81d5b
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json
@@ -0,0 +1,169 @@
+{
+ "id": "43f36e14-e3f5-43c1-84c0-50a9c80dc5c7",
+ "name": "MapLibre",
+ "zoom": 0.861983335785597,
+ "pitch": 0,
+ "center": [17.6543171043124, 32.9541203267468],
+ "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
+ "layers": [
+ {
+ "id": "background",
+ "type": "background",
+ "paint": {
+ "background-color": "#121217"
+ },
+ "filter": ["all"],
+ "layout": {
+ "visibility": "visible"
+ },
+ "maxzoom": 24
+ },
+ {
+ "id": "coastline",
+ "type": "line",
+ "paint": {
+ "line-blur": 0.5,
+ "line-color": "#000000",
+ "line-width": {
+ "stops": [
+ [0, 2],
+ [6, 6],
+ [14, 9],
+ [22, 18]
+ ]
+ }
+ },
+ "filter": ["all"],
+ "layout": {
+ "line-cap": "round",
+ "line-join": "round",
+ "visibility": "visible"
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "minzoom": 0,
+ "source-layer": "countries"
+ },
+ {
+ "id": "countries-fill",
+ "type": "fill",
+ "paint": {
+ "fill-color": "#292929"
+ },
+ "filter": ["all"],
+ "layout": {
+ "visibility": "visible"
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "source-layer": "countries"
+ },
+ {
+ "id": "countries-boundary",
+ "type": "line",
+ "paint": {
+ "line-color": "#484848",
+ "line-width": {
+ "stops": [
+ [1, 1],
+ [6, 2],
+ [14, 6],
+ [22, 12]
+ ]
+ },
+ "line-opacity": {
+ "stops": [
+ [3, 0.5],
+ [6, 1]
+ ]
+ }
+ },
+ "layout": {
+ "line-cap": "round",
+ "line-join": "round",
+ "visibility": "visible"
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "source-layer": "countries"
+ },
+ {
+ "id": "countries-label",
+ "type": "symbol",
+ "paint": {
+ "text-color": "rgba(8, 37, 77, 1)",
+ "text-halo-blur": {
+ "stops": [
+ [2, 0.2],
+ [6, 0]
+ ]
+ },
+ "text-halo-color": "rgba(255, 255, 255, 1)",
+ "text-halo-width": {
+ "stops": [
+ [2, 1],
+ [6, 1.6]
+ ]
+ }
+ },
+ "filter": ["all"],
+ "layout": {
+ "text-font": ["Open Sans Semibold"],
+ "text-size": {
+ "stops": [
+ [2, 10],
+ [4, 12],
+ [6, 16]
+ ]
+ },
+ "text-field": {
+ "stops": [
+ [2, "{ABBREV}"],
+ [4, "{NAME}"]
+ ]
+ },
+ "visibility": "visible",
+ "text-max-width": 10,
+ "text-transform": {
+ "stops": [
+ [0, "uppercase"],
+ [2, "none"]
+ ]
+ }
+ },
+ "source": "maplibre",
+ "maxzoom": 24,
+ "minzoom": 2,
+ "source-layer": "centroids"
+ },
+ {
+ "id": "data-dots",
+ "type": "circle",
+ "source": "data-dots",
+ "paint": {
+ "circle-radius": 3,
+ "circle-color": ["get", "color"],
+ "circle-opacity": 0.5
+ }
+ }
+ ],
+ "bearing": 0,
+ "sources": {
+ "maplibre": {
+ "url": "https://demotiles.maplibre.org/tiles/tiles.json",
+ "type": "vector"
+ },
+ "data-dots": {
+ "type": "geojson",
+ "data": {
+ "type": "FeatureCollection",
+ "features": []
+ }
+ }
+ },
+ "version": 8,
+ "metadata": {
+ "maptiler:copyright": "This style was generated on MapTiler Cloud. Usage is governed by the license terms in https://github.com/maplibre/demotiles/blob/gh-pages/LICENSE",
+ "openmaptiles:version": "3.x"
+ }
+}
\ No newline at end of file
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx
new file mode 100644
index 000000000..e7c0c961a
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx
@@ -0,0 +1,88 @@
+import "maplibre-gl/dist/maplibre-gl.css";
+import PropTypes from "prop-types";
+import { useRef, useState, useEffect } from "react";
+import { useTheme } from "@mui/material/styles";
+import style from "./DistributedUptimeMapStyle.json";
+import maplibregl from "maplibre-gl";
+const DistributedUptimeMap = ({ width = "100%", height = "100%", checks }) => {
+ const mapContainer = useRef(null);
+ const map = useRef(null);
+ const theme = useTheme();
+ const [mapLoaded, setMapLoaded] = useState(false);
+
+ const colorLookup = (avgResponseTime) => {
+ if (avgResponseTime <= 150) {
+ return "#00FF00"; // Green
+ } else if (avgResponseTime <= 250) {
+ return "#FFFF00"; // Yellow
+ } else {
+ return "#FF0000"; // Red
+ }
+ };
+
+ useEffect(() => {
+ if (mapContainer.current && !map.current) {
+ map.current = new maplibregl.Map({
+ container: mapContainer.current,
+ style,
+ center: [0, 20],
+ zoom: 0.8,
+ });
+ }
+ map.current.on("load", () => {
+ setMapLoaded(true);
+ });
+
+ return () => {
+ if (map.current) {
+ map.current.remove();
+ map.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (map.current && checks?.length > 0) {
+ // Convert dots to GeoJSON
+ const geojson = {
+ type: "FeatureCollection",
+ features: checks.map((check) => {
+ return {
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [check.lng, check.lat],
+ },
+ properties: {
+ color: theme.palette.accent.main,
+ // color: colorLookup(check.avgResponseTime) || "blue", // Default to blue if no color specified
+ },
+ };
+ }),
+ };
+
+ // Update the source with new dots
+ const source = map.current.getSource("data-dots");
+ if (source) {
+ source.setData(geojson);
+ }
+ }
+ }, [checks, theme, mapLoaded]);
+ return (
+
+ );
+};
+
+DistributedUptimeMap.propTypes = {
+ checks: PropTypes.array,
+ width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+ height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+};
+
+export default DistributedUptimeMap;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx
new file mode 100644
index 000000000..56d2f9618
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx
@@ -0,0 +1,214 @@
+import PropTypes from "prop-types";
+import {
+ AreaChart,
+ Area,
+ XAxis,
+ Tooltip,
+ CartesianGrid,
+ ResponsiveContainer,
+ Text,
+} from "recharts";
+import { Box, Stack, Typography } from "@mui/material";
+import { useTheme } from "@emotion/react";
+import { useState } from "react";
+import { useSelector } from "react-redux";
+import { formatDateWithTz } from "../../../../../Utils/timeUtils";
+
+const CustomToolTip = ({ active, payload, label }) => {
+ const uiTimezone = useSelector((state) => state.ui.timezone);
+ const theme = useTheme();
+ if (active && payload && payload.length) {
+ const responseTime = payload[0]?.payload?.originalAvgResponseTime
+ ? payload[0]?.payload?.originalAvgResponseTime
+ : (payload[0]?.payload?.avgResponseTime ?? 0);
+ return (
+
+
+ {formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
+
+
+
+
+
+ Response Time
+ {" "}
+
+ {Math.floor(responseTime)}
+
+ {" "}
+ ms
+
+
+
+
+ {/* Display original value */}
+
+ );
+ }
+ return null;
+};
+
+CustomToolTip.propTypes = {
+ active: PropTypes.bool,
+ payload: PropTypes.arrayOf(
+ PropTypes.shape({
+ value: PropTypes.number,
+ payload: PropTypes.shape({
+ _id: PropTypes.string,
+ avgResponseTime: PropTypes.number,
+ originalAvgResponseTime: PropTypes.number,
+ }),
+ })
+ ),
+ label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+};
+const CustomTick = ({ x, y, payload, index }) => {
+ const theme = useTheme();
+
+ const uiTimezone = useSelector((state) => state.ui.timezone);
+ // Render nothing for the first tick
+ if (index === 0) return null;
+ return (
+
+ {formatDateWithTz(payload?.value, "h:mm a", uiTimezone)}
+
+ );
+};
+
+CustomTick.propTypes = {
+ x: PropTypes.number,
+ y: PropTypes.number,
+ payload: PropTypes.object,
+ index: PropTypes.number,
+};
+
+const DistributedUptimeResponseChart = ({ checks }) => {
+ const theme = useTheme();
+ const [isHovered, setIsHovered] = useState(false);
+
+ if (checks.length === 0) return null;
+ return (
+
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+
+
+
+
+
+ }
+ minTickGap={0}
+ axisLine={false}
+ tickLine={false}
+ height={20}
+ />
+ }
+ wrapperStyle={{ pointerEvents: "none" }}
+ />
+
+
+
+ );
+};
+
+DistributedUptimeResponseChart.propTypes = {
+ checks: PropTypes.array,
+};
+
+export default DistributedUptimeResponseChart;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx
new file mode 100644
index 000000000..8032b6527
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx
@@ -0,0 +1,31 @@
+import { Stack, Typography, Box } from "@mui/material";
+import SolanaLogo from "../../../../../assets/icons/solana_logo.svg?react";
+import { useTheme } from "@mui/material/styles";
+const Footer = () => {
+ const theme = useTheme();
+ return (
+
+ Made with ❤️ by UpRock & Bluewave Labs
+
+ Built on
+
+ Solana
+
+
+ );
+};
+
+export default Footer;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx
new file mode 100644
index 000000000..a095b5feb
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx
@@ -0,0 +1,15 @@
+import { useState, useEffect } from "react";
+const LastUpdate = ({ suffix, lastUpdateTime, trigger }) => {
+ const [elapsedMs, setElapsedMs] = useState(lastUpdateTime);
+
+ useEffect(() => {
+ setElapsedMs(lastUpdateTime);
+ const timer = setInterval(() => {
+ setElapsedMs((prev) => prev + 1000);
+ }, 1000);
+ return () => clearInterval(timer);
+ }, [lastUpdateTime, trigger]);
+
+ return `${Math.floor(elapsedMs / 1000)} ${suffix}`;
+};
+export default LastUpdate;
diff --git a/Client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx b/Client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx
new file mode 100644
index 000000000..03949a9f7
--- /dev/null
+++ b/Client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx
@@ -0,0 +1,30 @@
+import { LinearProgress } from "@mui/material";
+import { useState, useEffect } from "react";
+
+const NextExpectedCheck = ({ lastUpdateTime, interval, trigger }) => {
+ const [elapsedMs, setElapsedMs] = useState(lastUpdateTime);
+
+ useEffect(() => {
+ setElapsedMs(lastUpdateTime);
+ const timer = setInterval(() => {
+ setElapsedMs((prev) => {
+ const newElapsedMs = prev + 100;
+ return newElapsedMs;
+ });
+ }, 100);
+ return () => clearInterval(timer);
+ }, [interval, trigger]);
+
+ return (
+
+ );
+};
+
+export default NextExpectedCheck;