v2 infra migration

This commit is contained in:
Alex Holliday
2026-01-28 21:34:43 +00:00
parent 5596e45ff3
commit c18159afd3
14 changed files with 617 additions and 725 deletions
@@ -0,0 +1,106 @@
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>
);
};
@@ -15,3 +15,4 @@ export { default as Icon } from "./Icon";
export * from "./Tooltip";
export * from "./StatBox";
export * from "./BaseChart";
export * from "./Gauge";
@@ -11,6 +11,7 @@ const statuses = ["up", "down"];
const states = ["active", "paused"];
export const ControlsFilter = ({
showTypes = true,
selectedTypes,
setSelectedTypes,
selectedStatus,
@@ -19,8 +20,9 @@ export const ControlsFilter = ({
setSelectedState,
onClearFilters,
}: {
selectedTypes: MonitorType[];
setSelectedTypes: React.Dispatch<React.SetStateAction<MonitorType[]>>;
showTypes?: boolean;
selectedTypes?: MonitorType[];
setSelectedTypes?: React.Dispatch<React.SetStateAction<MonitorType[]>>;
selectedStatus: string;
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
selectedState: string;
@@ -30,27 +32,29 @@ export const ControlsFilter = ({
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== "";
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
return (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
<Select
multiple
placeholder="Type"
value={selectedTypes}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
{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 textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
)}
<Select
placeholder="Status"
value={selectedStatus}
@@ -1,94 +0,0 @@
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import FilterHeader from "@/Components/v1/FilterHeader/index.jsx";
import { useMemo } from "react";
import { Box, Button } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import { useTranslation } from "react-i18next";
/**
* Filter Component
*
* A high-level component that provides filtering options for status in Infrastructure Page.
* It allows users to select multiple options for each filter and reset the filters.
*
* @component
* @param {Object} props - The component props.
* @param {string[]} props.selectedStatus - An array of selected status values.
* @param {function} props.setSelectedStatus - A function to set the selected status values.
* @param {function} props.setToFilterStatus - A function to set the filter status based on selected status values.
* @param {function} props.handleReset - A function to reset all filters.
*
* @returns {JSX.Element} The rendered Filter component.
*/
const statusOptions = [
{ value: "Up", label: "Up" },
{ value: "Down", label: "Down" },
];
const Filter = ({
selectedStatus,
setSelectedStatus,
setToFilterStatus,
handleReset,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const handleStatusChange = (event) => {
const selectedValues = event.target.value;
setSelectedStatus(selectedValues.length > 0 ? selectedValues : undefined);
if (selectedValues.length === 0 || selectedValues.length === 2) {
setToFilterStatus(undefined);
} else {
setToFilterStatus(selectedValues[0] === "Up" ? "true" : "false");
}
};
const isFilterActive = useMemo(() => {
return (selectedStatus?.length ?? 0) > 0;
}, [selectedStatus]);
return (
<Box
sx={{
m: theme.spacing(2),
ml: theme.spacing(4),
}}
>
<FilterHeader
header={t("status")}
options={statusOptions}
value={selectedStatus}
onChange={handleStatusChange}
/>
<Button
color={theme.palette.primary.contrastText}
onClick={handleReset}
variant="contained"
endIcon={
<Icon
name="X"
size={18}
/>
}
sx={{
visibility: isFilterActive ? "visible" : "hidden",
}}
>
{t("reset")}
</Button>
</Box>
);
};
Filter.propTypes = {
selectedStatus: PropTypes.arrayOf(PropTypes.string),
setSelectedStatus: PropTypes.func.isRequired,
setToFilterStatus: PropTypes.func.isRequired,
handleReset: PropTypes.func.isRequired,
};
export default Filter;
@@ -0,0 +1,275 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Table, Pagination, StatusLabel, Gauge } from "@/Components/v2/design-elements";
import type { Header } from "@/Components/v2/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/v2/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
export const InfraMonitorsTable = ({
monitors,
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
monitors: Monitor[];
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const navigate = useNavigate();
const {
post,
// loading: isPatching,
// error: postError,
} = usePost<any, Monitor>();
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
return [
{
id: 1,
label: t("pages.common.monitors.actions.openSite"),
action: () => {
window.open(monitor.url, "_blank", "noreferrer");
},
closeMenu: true,
},
{
id: 2,
label: t("pages.common.monitors.actions.details"),
action: () => {
navigate(`${monitor.id}`);
},
},
{
id: 3,
label: t("pages.common.monitors.actions.incidents"),
action: () => {
navigate(`/incidents?monitorId=${monitor.id}`);
},
},
{
id: 4,
label: t("pages.common.monitors.actions.configure"),
action: () => {
navigate(`/infrastructure/configure/${monitor.id}`);
},
},
// {
// id: 5,
// label: "Clone",
// action: () => {
// },
// },
{
id: 6,
label:
monitor.isActive === false
? t("common.buttons.resume")
: t("common.buttons.pause"),
action: async () => {
await post(`/monitors/pause/${monitor.id}`, {});
refetch();
},
closeMenu: true,
},
{
id: 7,
label: (
<Typography color={theme.palette.error.main}>
{t("common.buttons.delete")}
</Typography>
),
action: () => {
setSelectedMonitor(monitor);
},
closeMenu: true,
},
];
};
const getHeaders = () => {
const renderSortIcon = (isActive: boolean) => (
<Box
width={16}
display="inline-flex"
justifyContent="center"
>
{isActive ? (
sortOrder === "asc" ? (
<ArrowUp size={16} />
) : (
<ArrowDown size={16} />
)
) : null}
</Box>
);
const headers: Header<Monitor>[] = [
{
id: "name",
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "name")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.name")}
{renderSortIcon(sortField === "name")}
</Typography>
),
render: (row) => {
return row.name;
},
},
{
id: "status",
content: (
<Typography
component="div"
display="inline-flex"
alignItems="center"
gap={theme.spacing(4)}
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Typography>
),
render: (row) => {
return (
<StatusLabel
status={row.status}
isActive={row.isActive}
/>
);
},
},
{
id: "cpu",
content: t("pages.infrastructure.table.headers.cpu"),
render: (row) => {
const check = row.recentChecks?.[0];
const cpuUsage = (check?.cpu?.usage_percent || 0) * 100;
return <Gauge progress={cpuUsage} />;
},
},
{
id: "memory",
content: t("pages.infrastructure.table.headers.memory"),
render: (row) => {
const check = row.recentChecks?.[0];
const memoryUsage = (check?.memory?.usage_percent || 0) * 100;
return <Gauge progress={memoryUsage} />;
},
},
{
id: "disk",
content: t("pages.infrastructure.table.headers.disk"),
render: (row) => {
const check = row.recentChecks?.[0];
const totalDiskUsage = check?.disk?.reduce(
(acc, disk) => acc + (disk?.usage_percent || 0),
0
);
const diskCount = check?.disk?.length || 1;
const diskUsage = ((totalDiskUsage || 0) / diskCount) * 100;
return <Gauge progress={diskUsage} />;
},
},
{
id: "actions",
content: t("common.table.headers.actions"),
render: (row) => {
return <ActionsMenu items={getActions(row)} />;
},
},
];
return headers;
};
let headers = getHeaders();
if (isSmall) {
headers = headers.filter((h) => h.id !== "histogram");
}
return (
<Box>
<Table
headers={headers}
data={monitors}
onRowClick={(row) => {
navigate(`/infrastructure/${row.id}`);
}}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
@@ -1,162 +0,0 @@
// Components
import { Box } from "@mui/material";
import DataTable from "@/Components/v1/Table/index.jsx";
import Host from "@/Components/v1/Host/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { Stack } from "@mui/material";
import { InfrastructureMenu } from "../MonitorsTableMenu/index.jsx";
import LoadingSpinner from "../../../../Uptime/Monitors/Components/LoadingSpinner/index.jsx";
// Assets
import Icon from "@/Components/v1/Icon";
import CustomGauge from "@/Components/v1/Charts/CustomGauge/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../../../../Hooks/useMonitorUtils.js";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const MonitorsTable = ({
isLoading,
monitors,
isAdmin,
handleActionMenuDelete,
isSearching,
}) => {
// Utils
const theme = useTheme();
const { t } = useTranslation();
const { determineState } = useMonitorUtils();
const navigate = useNavigate();
// Handlers
const openDetails = (id) => {
navigate(`/infrastructure/${id}`);
};
const headers = [
{
id: "host",
content: t("host"),
render: (row) => (
<Host
title={row.name}
url={row.url}
percentage={row.uptimePercentage}
percentageColor={row.percentageColor}
/>
),
},
{
id: "status",
content: t("incidentsTableStatus"),
render: (row) => (
<StatusLabel
status={row.status}
text={row.status}
/>
),
},
{
id: "frequency",
content: t("frequency"),
render: (row) => (
<Stack
direction={"row"}
justifyContent={"center"}
alignItems={"center"}
gap=".25rem"
>
<Icon
name="Cpu"
size={20}
/>
{row.processor}
</Stack>
),
},
{ id: "cpu", content: t("cpu"), render: (row) => <CustomGauge progress={row.cpu} /> },
{
id: "memory",
content: t("memory"),
render: (row) => <CustomGauge progress={row.mem} />,
},
{
id: "disk",
content: t("disk"),
render: (row) => <CustomGauge progress={row.disk} />,
},
{
id: "actions",
content: t("actions"),
render: (row) => (
<InfrastructureMenu
monitor={row}
isAdmin={isAdmin}
updateCallback={handleActionMenuDelete}
isLoading={isLoading}
/>
),
},
];
const data = monitors?.map((monitor) => {
const processor =
((monitor.recentChecks?.[0]?.cpu?.frequency ?? 0) / 1000).toFixed(2) + " GHz";
const cpu = (monitor?.recentChecks?.[0]?.cpu?.usage_percent ?? 0) * 100;
const mem = (monitor?.recentChecks?.[0]?.memory?.usage_percent ?? 0) * 100;
const disk = (monitor?.recentChecks?.[0]?.disk?.[0]?.usage_percent ?? 0) * 100;
const status = determineState(monitor);
const percentageColor =
monitor.uptimePercentage < 0.25
? theme.palette.error.main
: monitor.uptimePercentage < 0.5
? theme.palette.warning.main
: theme.palette.success.main;
return {
id: monitor.id,
name: monitor.name,
url: monitor.url,
processor,
cpu,
mem,
disk,
status,
percentageColor,
};
});
return (
<Box position="relative">
<LoadingSpinner shouldRender={isSearching} />
<DataTable
shouldRender={!isLoading}
headers={headers}
data={data}
config={{
/* TODO this behavior seems to be repeated. Put it on the root table? */
rowSX: {
cursor: "pointer",
"&:hover td": {
backgroundColor: theme.palette.tertiary.main,
transition: "background-color .3s ease",
},
},
onRowClick: (row) => openDetails(row.id),
emptyView: "No monitors found",
}}
/>
</Box>
);
};
MonitorsTable.propTypes = {
isLoading: PropTypes.bool,
monitors: PropTypes.array,
isAdmin: PropTypes.bool,
handleActionMenuDelete: PropTypes.func,
isSearching: PropTypes.bool,
};
export default MonitorsTable;
@@ -1,196 +0,0 @@
/* TODO I basically copied and pasted this component from the actionsMenu. Check how we can make it reusable */
import { useRef, useState } from "react";
import { useTheme } from "@emotion/react";
import { useNavigate } from "react-router-dom";
import { createToast } from "@/Utils/toastUtils.jsx";
import { IconButton, Menu, MenuItem } from "@mui/material";
import Icon from "@/Components/v1/Icon";
import PropTypes from "prop-types";
import Dialog from "@/Components/v1/Dialog/index.jsx";
import { networkService } from "@/Utils/NetworkService.js";
import { usePauseMonitor } from "@/Hooks/monitorHooks.js";
import { useTranslation } from "react-i18next";
/**
* InfrastructureMenu Component
* Provides a dropdown menu for managing infrastructure monitors.
*
* @param {Object} props - The component props.
* @param {Object} props.monitor - The monitor object containing details about the infrastructure monitor.
* @param {string} props.monitor.id - Unique ID of the monitor.
* @param {string} [props.monitor.url] - URL associated with the monitor.
* @param {string} props.monitor.type - Type of monitor (e.g., uptime, infrastructure).
* @param {boolean} props.monitor.isActive - Indicates if the monitor is currently active (true) or paused (false).
* @param {boolean} props.isAdmin - Whether the user has admin privileges.
* @param {Function} props.updateCallback - Callback to trigger when the monitor data is updated.
* @returns {JSX.Element} The rendered component.
*/
const InfrastructureMenu = ({ monitor, isAdmin, updateCallback }) => {
const anchor = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const theme = useTheme();
const [pauseMonitor] = usePauseMonitor();
const { t } = useTranslation();
const openMenu = (e) => {
e.stopPropagation();
setIsOpen(true);
};
const closeMenu = (e) => {
e.stopPropagation();
setIsOpen(false);
};
const openRemove = (e) => {
closeMenu(e);
setIsDialogOpen(true);
};
const cancelRemove = () => {
setIsDialogOpen(false);
};
const navigate = useNavigate();
function openDetails(id) {
navigate(`/infrastructure/${id}`);
}
const openConfigure = (id) => {
navigate(`/infrastructure/configure/${id}`);
};
const handlePause = async () => {
// Pass updateCallback as triggerUpdate to the hook
await pauseMonitor({ monitorId: monitor.id, triggerUpdate: updateCallback });
// Toast is already displayed in the hook, no need to display it again
};
const handleRemove = async () => {
try {
await networkService.deleteMonitorById({
monitorId: monitor.id,
});
createToast({
body: t("monitorActions.deleteSuccess"),
});
} catch (error) {
createToast({ body: t("monitorActions.deleteFailed") });
} finally {
setIsDialogOpen(false);
updateCallback();
}
};
return (
<>
<IconButton
aria-label="monitor actions"
onClick={openMenu}
disabled={!isAdmin}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
ref={anchor}
>
<Icon
name="Settings"
size={20}
/>
</IconButton>
<Menu
className="actions-menu"
anchorEl={anchor.current}
open={isOpen}
onClose={closeMenu}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": { p: theme.spacing(2.5) },
"& li": { m: 0 },
"& li:last-of-type": {
color: theme.palette.error.main,
},
},
},
}}
>
<MenuItem
onClick={(e) => {
e.stopPropagation();
openDetails(monitor.id);
closeMenu(e);
}}
>
{t("monitorActions.details")}
</MenuItem>
{isAdmin && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openConfigure(monitor.id);
closeMenu(e);
}}
>
{t("configure")}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={async (e) => {
e.stopPropagation();
await handlePause();
closeMenu(e);
}}
>
{!monitor.isActive ? t("resume") : t("pause")}
</MenuItem>
)}
{isAdmin && (
<MenuItem
onClick={openRemove}
sx={{ color: theme.palette.error.main }}
>
{t("remove")}
</MenuItem>
)}
</Menu>
<Dialog
open={isDialogOpen}
theme={theme}
title={t("deleteDialogTitle")}
description={t("deleteDialogDescription")}
onCancel={cancelRemove}
confirmationButtonLabel={t("delete")}
onConfirm={handleRemove}
modelTitle="modal-delete-monitor"
modelDescription="delete-monitor-confirmation"
/>
</>
);
};
InfrastructureMenu.propTypes = {
monitor: PropTypes.shape({
id: PropTypes.string.isRequired,
url: PropTypes.string,
// Note: type must remain optional. Making it required (type: PropTypes.string.isRequired)
// causes runtime errors as some monitors don't have a defined type property
type: PropTypes.string,
isActive: PropTypes.bool, // Determines whether the monitor is paused (false) or active (true)
status: PropTypes.string, // Represents the monitor's operational status (e.g., 'up', 'down', etc.)
}).isRequired,
isAdmin: PropTypes.bool.isRequired,
updateCallback: PropTypes.func.isRequired,
};
export { InfrastructureMenu };
@@ -1,5 +1,200 @@
import Stack from "@mui/material/Stack";
import useMediaQuery from "@mui/material/useMediaQuery";
import {
MonitorBasePageWithStates,
UpStatusBox,
DownStatusBox,
PausedStatusBox,
} from "@/Components/v2/design-elements";
import { HeaderCreate } from "@/Components/v2/common";
import { ControlsFilter } from "@/Components/v2/monitors";
import { TextField, Dialog } from "@/Components/v2/inputs";
import { useGet, useDelete } from "@/Hooks/UseApi";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
import { useTheme } from "@mui/material";
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
import type { RootState } from "@/Types/state";
import { InfraMonitorsTable } from "./Components/MonitorsTable";
const InfrastructureMonitors = () => {
return null;
const { t } = useTranslation();
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
const dispatch = useDispatch();
// Redux state
const rowsPerPage = useSelector(
(state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
);
// Local state
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [selectedState, setSelectedState] = useState<string>("");
const [search, setSearch] = useState<string>("");
const [page, setPage] = useState<number>(0);
const [sortField, setSortField] = useState<string>("");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const isDialogOpen = Boolean(selectedMonitor);
const handleClearFilters = useCallback(() => {
setSelectedStatus("");
setSelectedState("");
setSearch("");
}, []);
// Convert filter selections to API filter values
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
return undefined;
}, [selectedStatus]);
const toFilterActive = useMemo(() => {
if (selectedState === "active") return "true";
if (selectedState === "paused") return "false";
return undefined;
}, [selectedState]);
// Determine field and filter for the API request
// Priority: status > isActive > search
const filterLookup = new Map<string | undefined, string>([
[toFilterStatus, "status"],
[toFilterActive, "isActive"],
]);
const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
const field = activeFilter?.[1] || (search ? "name" : sortField || undefined);
const filter = activeFilter?.[0] || search || undefined;
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "hardware");
params.append("limit", "1");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (filter) params.append("filter", filter);
if (field) params.append("field", field);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, filter, field, sortOrder]);
const {
data: monitors,
isLoading: monitorsLoading,
error,
refetch: refetchMonitors,
} = useGet<Monitor[]>("/monitors/team?type=hardware", {}, { keepPreviousData: true });
const {
data: monitorsWithChecksData,
isLoading: monitorsWithChecksLoading,
error: monitorsWithChecksError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 5000, keepPreviousData: true }
);
const { summary, count } = monitorsWithChecksData ?? {};
const isLoading = monitorsLoading || monitorsWithChecksLoading;
// Delete hook
const { deleteFn, loading: isDeleting } = useDelete();
const handleConfirm = async () => {
if (!selectedMonitor) return;
await deleteFn(`/monitors/${selectedMonitor.id}`);
setSelectedMonitor(null);
refetch();
refetchMonitors();
};
const handleCancel = () => {
setSelectedMonitor(null);
};
return (
<MonitorBasePageWithStates
loading={isLoading}
error={error ?? monitorsWithChecksError}
items={monitors || []}
page="infrastructure"
actionLink="/infrastructure/create"
>
<HeaderCreate
path="/infrastructure/create"
isLoading={isLoading}
isAdmin={isAdmin}
/>
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(8)}
>
<UpStatusBox n={summary?.upMonitors || 0} />
<DownStatusBox n={summary?.downMonitors || 0} />
<PausedStatusBox n={summary?.pausedMonitors || 0} />
</Stack>
<Stack
direction={isSmall ? "column" : "row"}
justifyContent={isSmall ? "flex-start" : "space-between"}
gap={theme.spacing(4)}
>
<ControlsFilter
showTypes={false}
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
selectedState={selectedState}
setSelectedState={setSelectedState}
onClearFilters={handleClearFilters}
/>
<TextField
placeholder={t("pages.uptime.filters.search.placeholder")}
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
</Stack>
<InfraMonitorsTable
monitors={monitorsWithChecksData?.monitors || []}
refetch={refetch}
setSelectedMonitor={setSelectedMonitor}
sortField={sortField}
setSortField={setSortField}
sortOrder={sortOrder}
setSortOrder={setSortOrder}
count={count || 0}
page={page}
setPage={setPage}
rowsPerPage={rowsPerPage}
setRowsPerPage={(value: number) => {
dispatch(
setRowsPerPage({
value,
table: "infrastructure",
})
);
setPage(0);
}}
/>
<Dialog
open={isDialogOpen}
title={t("common.dialogs.delete.title")}
content={t("common.dialogs.delete.description")}
onConfirm={handleConfirm}
onCancel={handleCancel}
loading={isDeleting}
/>
</MonitorBasePageWithStates>
);
};
export default InfrastructureMonitors;
@@ -1,139 +0,0 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import MonitorCountHeader from "@/Components/v1/MonitorCountHeader/index.jsx";
import MonitorCreateHeader from "@/Components/v1/MonitorCreateHeader/index.jsx";
import MonitorsTable from "./Components/MonitorsTable/index.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import PageStateWrapper from "@/Components/v1/PageStateWrapper/index.jsx";
import Filter from "./Components/Filters/index.jsx";
import SearchComponent from "../../Uptime/Monitors/Components/SearchComponent/index.jsx";
// Utils
import { useTheme } from "@emotion/react";
import { useEffect, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import { useTranslation } from "react-i18next";
import { useFetchMonitorsWithChecks } from "@/Hooks/monitorHooks.js";
import { useDispatch, useSelector } from "react-redux";
import { setRowsPerPage } from "../../../Features/UI/uiSlice.js";
// Constants
const TYPES = ["hardware"];
const BREADCRUMBS = [{ name: `infrastructure`, path: "/infrastructure" }];
const InfrastructureMonitors = () => {
// Redux state
const rowsPerPage = useSelector((state) => state.ui?.infrastructure?.rowsPerPage ?? 5);
const dispatch = useDispatch();
// Local state
const [page, setPage] = useState(0);
const [updateTrigger, setUpdateTrigger] = useState(false);
const [selectedStatus, setSelectedStatus] = useState(undefined);
const [toFilterStatus, setToFilterStatus] = useState(undefined);
const [search, setSearch] = useState(undefined);
const [isSearching, setIsSearching] = useState(false);
// Utils
const theme = useTheme();
const isAdmin = useIsAdmin();
const { t } = useTranslation();
// Handlers
const handleActionMenuDelete = () => {
setUpdateTrigger(!updateTrigger);
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
dispatch(
setRowsPerPage({
value: parseInt(event.target.value, 10),
table: "infrastructure",
})
);
setPage(0);
};
useEffect(() => {
if (isSearching) {
setPage(0);
}
}, [isSearching]);
const handleReset = () => {
setSelectedStatus(undefined);
setToFilterStatus(undefined);
};
const field = toFilterStatus !== undefined ? "status" : undefined;
const [summary, monitors, count, isLoading, networkError] = useFetchMonitorsWithChecks({
types: TYPES,
limit: 1,
page: page,
field: field,
filter: toFilterStatus ?? search,
rowsPerPage: rowsPerPage,
monitorUpdateTrigger: updateTrigger,
});
return (
<>
<PageStateWrapper
networkError={networkError}
isLoading={isLoading}
items={monitors}
type="infrastructureMonitor"
fallbackLink="/infrastructure/create"
>
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<MonitorCreateHeader
isAdmin={isAdmin}
isLoading={isLoading}
path="/infrastructure/create"
/>
<Stack direction={"row"}>
<MonitorCountHeader
isLoading={isLoading}
monitorCount={count || 0}
/>
<Filter
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
setToFilterStatus={setToFilterStatus}
handleReset={handleReset}
/>
<SearchComponent
monitors={monitors}
onSearchChange={setSearch}
setIsSearching={setIsSearching}
/>
</Stack>
<MonitorsTable
isLoading={isLoading}
monitors={monitors}
isAdmin={isAdmin}
handleActionMenuDelete={handleActionMenuDelete}
isSearching={isSearching}
/>
<Pagination
itemCount={count || 0}
paginationLabel={t("monitors")}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</Stack>
</PageStateWrapper>
</>
);
};
export default InfrastructureMonitors;
@@ -1,46 +0,0 @@
import { CircularProgress, Box } from "@mui/material";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
const LoadingSpinner = ({ shouldRender }) => {
const theme = useTheme();
if (shouldRender === false) {
return;
}
return (
<>
<Box
width="100%"
height="100%"
position="absolute"
sx={{
backgroundColor: theme.palette.primary.main,
opacity: 0.8,
zIndex: 100,
}}
/>
<Box
height="100%"
position="absolute"
top="50%"
left="50%"
sx={{
transform: "translateX(-50%)",
zIndex: 101,
}}
>
<CircularProgress
sx={{
color: theme.palette.accent.main,
}}
/>
</Box>
</>
);
};
LoadingSpinner.propTypes = {
shouldRender: PropTypes.bool,
};
export default LoadingSpinner;
@@ -1,49 +0,0 @@
import { useState } from "react";
import Search from "@/Components/v1/Inputs/Search/index.jsx";
import { Box } from "@mui/material";
import useDebounce from "../../Hooks/useDebounce.jsx";
import { useEffect, useRef } from "react";
import PropTypes from "prop-types";
const SearchComponent = ({ monitors = [], onSearchChange, setIsSearching }) => {
const isFirstRender = useRef(true);
const [localSearch, setLocalSearch] = useState("");
const debouncedSearch = useDebounce(localSearch, 500);
useEffect(() => {
if (isFirstRender.current === true) {
isFirstRender.current = false;
return;
}
onSearchChange(debouncedSearch);
setIsSearching(false);
}, [debouncedSearch, onSearchChange, setIsSearching]);
const handleSearch = (value) => {
setLocalSearch(value);
setIsSearching(true);
};
return (
<Box
width="25%"
minWidth={150}
ml="auto"
mt={2}
>
<Search
options={monitors}
filteredBy="name"
inputValue={localSearch}
handleInputChange={handleSearch}
/>
</Box>
);
};
SearchComponent.propTypes = {
monitors: PropTypes.array,
onSearchChange: PropTypes.func,
setIsSearching: PropTypes.func,
};
export default SearchComponent;
@@ -1,18 +0,0 @@
import { useState, useEffect } from "react";
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
export default useDebounce;
+8 -2
View File
@@ -25,7 +25,7 @@ import PageSpeedDetails from "../Pages/PageSpeed/Details/";
import PageSpeedCreate from "../Pages/PageSpeed/Create/index.jsx";
// Infrastructure
import Infrastructure from "../Pages/Infrastructure/Monitors/index.jsx";
import Infrastructure from "../Pages/Infrastructure/Monitors";
import InfrastructureCreate from "../Pages/Infrastructure/Create/index.jsx";
import InfrastructureDetails from "../Pages/Infrastructure/Details/index.jsx";
@@ -147,7 +147,13 @@ const Routes = () => {
/>
<Route
path="infrastructure"
element={<Infrastructure />}
element={
<>
<ThemeProvider theme={v2theme}>
<Infrastructure />
</ThemeProvider>
</>
}
/>
<Route
path="infrastructure/create"
+9
View File
@@ -276,6 +276,15 @@
"weight": "Weight"
}
}
},
"infrastructure": {
"table": {
"headers": {
"cpu": "CPU",
"disk": "Disk",
"memory": "Memory"
}
}
}
},
"incidentsTableNoIncidents": "No incidents recorded",