Merge branch 'feature/add-helm-chart' of https://github.com/JefferMarcelino/Checkmate into feature/add-helm-chart

This commit is contained in:
Jeffer Marcelino
2025-07-13 19:18:10 +02:00
34 changed files with 669 additions and 143 deletions

View File

@@ -53,9 +53,8 @@ Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1).
See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
For detailed Kubernetes deployment instructions using Helm, see the [INSTALLATION.md](./charts/helm/checkmate/INSTALLATION.md) file.
Alternatively, you can also use [Coolify](https://coolify.io/), [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
Alternatively, you can also use [Coolify](https://coolify.io/) or [Elestio](https://elest.io/open-source/checkmate) for a one-click Docker deployment. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
## 🏁 Translations

View File

@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
import Status from "./status";
import Skeleton from "./skeleton";
import Button from "@mui/material/Button";
import { Tooltip } from "@mui/material";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import PauseOutlinedIcon from "@mui/icons-material/PauseOutlined";
import PlayArrowOutlinedIcon from "@mui/icons-material/PlayArrowOutlined";
@@ -40,6 +41,10 @@ const MonitorDetailsControlHeader = ({
const { t } = useTranslation();
const [pauseMonitor, isPausing, error] = usePauseMonitor();
const isTestNotificationsDisabled = monitor?.notifications?.length === 0;
const tooltipTitle = isTestNotificationsDisabled ? t("testNotificationsDisabled") : "";
// const [isSending, emailError, sendTestEmail] = useSendTestEmail();
const [testAllNotifications, isSending, errorAllNotifications] =
@@ -60,20 +65,29 @@ const MonitorDetailsControlHeader = ({
direction="row"
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isSending}
startIcon={<EmailIcon />}
onClick={() => {
testAllNotifications({ monitorId: monitor?._id });
}}
sx={{
whiteSpace: "nowrap",
}}
<Tooltip
key={monitor?._id}
placement="bottom"
title={tooltipTitle}
>
{t("sendTestNotifications")}
</Button>
<span>
<Button
variant="contained"
color="secondary"
loading={isSending}
startIcon={<EmailIcon />}
disabled={isTestNotificationsDisabled}
onClick={() => {
testAllNotifications({ monitorId: monitor?._id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("sendTestNotifications")}
</Button>
</span>
</Tooltip>
<Button
variant="contained"
color="secondary"

View File

@@ -214,7 +214,7 @@ const PasswordPanel = () => {
<TextInput
type="password"
id="edit-confirm-password"
placeholder={t("confirmPassword", "Confirm password")}
placeholder={t("confirmPassword")}
autoComplete="new-password"
value={localData.confirm}
onChange={handleChange}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { networkService } from "../main";
import { createToast } from "../Utils/toastUtils";
import { useTranslation } from "react-i18next";
const useFetchChecksTeam = ({
status,
@@ -12,6 +13,7 @@ const useFetchChecksTeam = ({
page,
rowsPerPage,
enabled = true,
updateTrigger,
}) => {
const [checks, setChecks] = useState(undefined);
const [checksCount, setChecksCount] = useState(undefined);
@@ -49,7 +51,18 @@ const useFetchChecksTeam = ({
};
fetchChecks();
}, [status, sortOrder, limit, dateRange, filter, ack, page, rowsPerPage, enabled]);
}, [
status,
sortOrder,
limit,
dateRange,
filter,
ack,
page,
rowsPerPage,
enabled,
updateTrigger,
]);
return [checks, checksCount, isLoading, networkError];
};
@@ -66,6 +79,7 @@ const useFetchChecksByMonitor = ({
page,
rowsPerPage,
enabled = true,
updateTrigger,
}) => {
const [checks, setChecks] = useState(undefined);
const [checksCount, setChecksCount] = useState(undefined);
@@ -117,9 +131,83 @@ const useFetchChecksByMonitor = ({
page,
rowsPerPage,
enabled,
updateTrigger,
]);
return [checks, checksCount, isLoading, networkError];
};
export { useFetchChecksByMonitor, useFetchChecksTeam };
const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
const [summary, setSummary] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [networkError, setNetworkError] = useState(false);
useEffect(() => {
const fetchSummary = async () => {
try {
setIsLoading(true);
const res = await networkService.getChecksAndSummaryByTeamId();
setSummary(res.data.data);
} catch (error) {
setNetworkError(true);
createToast({ body: error.message });
} finally {
setIsLoading(false);
}
};
fetchSummary();
}, [updateTrigger]);
return [summary, isLoading, networkError];
};
const useResolveIncident = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const resolveIncident = async (checkId, setUpdateTrigger) => {
try {
setIsLoading(true);
await networkService.updateCheckStatus({
checkId,
ack: true,
});
setUpdateTrigger((prev) => !prev);
} catch (error) {
createToast({ body: t("checkHooks.failureResolveOne") });
} finally {
setIsLoading(false);
}
};
return [resolveIncident, isLoading];
};
const useAckAllChecks = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const ackAllChecks = async (setUpdateTrigger) => {
try {
setIsLoading(true);
await networkService.updateAllChecksStatus({ ack: true });
setUpdateTrigger((prev) => !prev);
} catch (error) {
createToast({ body: t("checkHooks.failureResolveAll") });
} finally {
setIsLoading(false);
}
};
return [ackAllChecks, isLoading];
};
export {
useFetchChecksByMonitor,
useFetchChecksTeam,
useFetchChecksSummaryByTeamId,
useResolveIncident,
useAckAllChecks,
};

View File

@@ -385,9 +385,9 @@ const useAddDemoMonitors = () => {
try {
setIsLoading(true);
await networkService.addDemoMonitors();
createToast({ body: t("settingsDemoMonitorsAdded") });
createToast({ body: t("monitorHooks.successAddDemoMonitors") });
} catch (error) {
createToast({ body: t("settingsFailedToAddDemoMonitors") });
createToast({ body: t("monitorHooks.failureAddDemoMonitors") });
} finally {
setIsLoading(false);
}

View File

@@ -15,13 +15,17 @@ import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useFetchChecksTeam } from "../../../../Hooks/checkHooks";
import { useFetchChecksByMonitor } from "../../../../Hooks/checkHooks";
import { useResolveIncident } from "../../../../Hooks/checkHooks";
import { Button, Typography } from "@mui/material";
const IncidentTable = ({
shouldRender,
isLoading,
monitors,
selectedMonitor,
filter,
dateRange,
updateTrigger,
setUpdateTrigger,
}) => {
//Redux state
const uiTimezone = useSelector((state) => state.ui.timezone);
@@ -32,6 +36,9 @@ const IncidentTable = ({
const selectedMonitorDetails = monitors?.[selectedMonitor];
const selectedMonitorType = selectedMonitorDetails?.type;
//Hooks
const [resolveIncident, resolveLoading] = useResolveIncident();
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
useFetchChecksByMonitor({
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
@@ -45,6 +52,7 @@ const IncidentTable = ({
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor !== "0",
updateTrigger,
});
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
@@ -58,11 +66,12 @@ const IncidentTable = ({
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor === "0",
updateTrigger,
});
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
const isLoading = isLoadingTeam || isLoadingMonitor;
isLoading = isLoadingTeam || isLoadingMonitor;
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
const { t } = useTranslation();
@@ -76,6 +85,10 @@ const IncidentTable = ({
setRowsPerPage(event.target.value);
};
const handleResolveIncident = (checkId) => {
resolveIncident(checkId, setUpdateTrigger);
};
const headers = [
{
id: "monitorName",
@@ -114,9 +127,31 @@ const IncidentTable = ({
render: (row) => <HttpStatusLabel status={row.statusCode} />,
},
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
{
id: "action",
content: t("actions"),
render: (row) => {
return row.ack === false ? (
<Button
variant="contained"
color="accent"
onClick={() => {
handleResolveIncident(row._id);
}}
>
{t("incidentsTableActionResolve")}
</Button>
) : (
<Typography>
{t("incidentsTableResolvedAt")}{" "}
{formatDateWithTz(row.ackAt, "YYYY-MM-DD HH:mm:ss A", uiTimezone)}
</Typography>
);
},
},
];
if (!shouldRender || isLoading) return <TableSkeleton />;
if (isLoading || resolveLoading) return <TableSkeleton />;
if (networkError) {
return (
@@ -149,10 +184,12 @@ const IncidentTable = ({
};
IncidentTable.propTypes = {
shouldRender: PropTypes.bool,
isLoading: PropTypes.bool,
monitors: PropTypes.object,
selectedMonitor: PropTypes.string,
filter: PropTypes.string,
dateRange: PropTypes.string,
updateTrigger: PropTypes.bool,
setUpdateTrigger: PropTypes.func,
};
export default IncidentTable;

View File

@@ -21,6 +21,12 @@ const OptionsHeader = ({
const theme = useTheme();
const { t } = useTranslation();
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
const filterOptions = [
{ _id: "all", name: t("incidentsOptionsHeaderFilterAll") },
{ _id: "down", name: t("incidentsOptionsHeaderFilterDown") },
{ _id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") },
{ _id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") },
];
// The stacks below which are three in number have the same style so
const stackStyles = {
@@ -65,36 +71,16 @@ const OptionsHeader = ({
>
{t("incidentsOptionsHeaderFilterBy")}
</Typography>
<ButtonGroup>
<Button
variant="group"
filled={(filter === "all").toString()}
onClick={() => setFilter("all")}
>
{t("incidentsOptionsHeaderFilterAll")}
</Button>
<Button
variant="group"
filled={(filter === "down").toString()}
onClick={() => setFilter("down")}
>
{t("incidentsOptionsHeaderFilterDown")}
</Button>
<Button
variant="group"
filled={(filter === "resolve").toString()}
onClick={() => setFilter("resolve")}
>
{t("incidentsOptionsHeaderFilterCannotResolve")}
</Button>
{/* <Button
variant="group"
filled={(filter === "resolved").toString()}
onClick={() => setFilter("resolved")}
>
{t("incidentsOptionsHeaderFilterResolved")}
</Button> */}
</ButtonGroup>
<Select
id="incidents-select-filter"
value={filter}
onChange={(e) => setFilter(e.target.value)}
items={filterOptions}
sx={{
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastTextSecondary,
}}
/>
</Stack>
<Stack {...stackStyles}>
<Typography

View File

@@ -0,0 +1,116 @@
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { Box, Stack, Typography } from "@mui/material";
import Background from "../../../../assets/Images/background-grid.svg?react";
import MonitorHeartOutlinedIcon from "@mui/icons-material/MonitorHeartOutlined";
import TaskAltOutlinedIcon from "@mui/icons-material/TaskAltOutlined";
import CancelOutlinedIcon from "@mui/icons-material/CancelOutlined";
import WarningAmberRoundedIcon from "@mui/icons-material/WarningAmberRounded";
const StatusBox = ({ title, value, status }) => {
const theme = useTheme();
let sharedStyles = {
position: "absolute",
right: 8,
opacity: 0.5,
"& svg path": { stroke: theme.palette.primary.contrastTextTertiary },
};
let color;
let icon;
if (status === "up") {
color = theme.palette.success.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<TaskAltOutlinedIcon fontSize="small" />
</Box>
);
} else if (status === "down") {
color = theme.palette.error.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<CancelOutlinedIcon fontSize="small" />
</Box>
);
} else if (status === "paused") {
color = theme.palette.warning.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<WarningAmberRoundedIcon fontSize="small" />
</Box>
);
} else {
color = theme.palette.accent.main;
icon = (
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<MonitorHeartOutlinedIcon fontSize="small" />
</Box>
);
}
return (
<Box
position="relative"
flex={1}
border={1}
backgroundColor={theme.palette.primary.main}
borderColor={theme.palette.primary.lowContrast}
borderRadius={theme.shape.borderRadius}
p={theme.spacing(8)}
overflow="hidden"
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
<Stack direction="column">
<Stack
direction="row"
alignItems="center"
mb={theme.spacing(8)}
>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.primary.contrastTextTertiary}
>
{title}
</Typography>
{icon}
</Stack>
<Stack
direction="row"
alignItems="flex-start"
fontSize={theme.typography.h1.fontSize}
fontWeight={600}
color={color}
gap={theme.spacing(1)}
>
{value}
<Typography
fontSize={theme.typography.label.fontSize}
fontWeight={300}
color={theme.palette.primary.contrastTextSecondary}
sx={{
opacity: 0.3,
}}
>
#
</Typography>
</Stack>
</Stack>
</Box>
);
};
StatusBox.propTypes = {
title: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
status: PropTypes.string,
};
export default StatusBox;

View File

@@ -0,0 +1,46 @@
import PropTypes from "prop-types";
import { Stack } from "@mui/material";
import StatusBox from "./StatusBox";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import SkeletonLayout from "./skeleton";
const StatusBoxes = ({ isLoading, summary }) => {
const theme = useTheme();
const { t } = useTranslation();
if (isLoading) return <SkeletonLayout shouldRender={isLoading} />;
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<StatusBox
title={t("incidentsOptionsHeaderTotalIncidents")}
value={summary?.totalChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterResolved")}
status="up"
value={summary?.resolvedChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterCannotResolve")}
status="paused"
value={summary?.cannotResolveChecks || 0}
/>
<StatusBox
title={t("incidentsOptionsHeaderFilterDown")}
status="down"
value={summary?.downChecks || 0}
/>
</Stack>
);
};
StatusBoxes.propTypes = {
isLoading: PropTypes.bool,
summary: PropTypes.object,
};
export default StatusBoxes;

View File

@@ -0,0 +1,36 @@
import { Skeleton, Stack } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
gap={theme.spacing(12)}
direction="row"
justifyContent="space-between"
>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
<Skeleton
variant="rounded"
width="100%"
height={100}
/>
</Stack>
);
};
export default SkeletonLayout;

View File

@@ -4,10 +4,14 @@ import Breadcrumbs from "../../Components/Breadcrumbs";
import GenericFallback from "../../Components/GenericFallback";
import IncidentTable from "./Components/IncidentTable";
import OptionsHeader from "./Components/OptionsHeader";
import StatusBoxes from "./Components/StatusBoxes";
import { Box, Button } from "@mui/material";
//Utils
import { useTheme } from "@emotion/react";
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks";
import { useFetchChecksSummaryByTeamId } from "../../Hooks/checkHooks";
import { useAckAllChecks } from "../../Hooks/checkHooks";
import { useState, useEffect } from "react";
import NetworkError from "../../Components/GenericFallback/NetworkError";
import { useTranslation } from "react-i18next";
@@ -27,10 +31,17 @@ const Incidents = () => {
const [filter, setFilter] = useState(undefined);
const [dateRange, setDateRange] = useState(undefined);
const [monitorLookup, setMonitorLookup] = useState(undefined);
const [updateTrigger, setUpdateTrigger] = useState(false);
//Hooks
const [ackAllChecks, ackAllLoading] = useAckAllChecks();
//Utils
const theme = useTheme();
const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({});
const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({
updateTrigger,
});
const { monitorId } = useParams();
useEffect(() => {
@@ -51,7 +62,11 @@ const Incidents = () => {
setMonitorLookup(monitorLookup);
}, [monitors]);
if (networkError) {
const handleAckAllChecks = () => {
ackAllChecks(setUpdateTrigger);
};
if (networkError || networkErrorSummary) {
return (
<GenericFallback>
<NetworkError />
@@ -62,6 +77,20 @@ const Incidents = () => {
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<Box alignSelf="flex-end">
<Button
variant="contained"
color="accent"
onClick={handleAckAllChecks}
disabled={ackAllLoading}
>
{t("incidentsPageActionResolve")}
</Button>
</Box>
<StatusBoxes
isLoading={isLoadingSummary}
summary={summary}
/>
<OptionsHeader
shouldRender={!isLoading}
monitors={monitorLookup}
@@ -73,11 +102,13 @@ const Incidents = () => {
setDateRange={setDateRange}
/>
<IncidentTable
shouldRender={!isLoading}
isLoading={isLoading}
monitors={monitorLookup ? monitorLookup : {}}
selectedMonitor={selectedMonitor}
filter={filter}
dateRange={dateRange}
updateTrigger={updateTrigger}
setUpdateTrigger={setUpdateTrigger}
/>
</Stack>
);

View File

@@ -87,10 +87,7 @@ const SettingsEmail = ({
!emailConfig.systemEmailPassword
) {
createToast({
body: t(
"settingsEmailRequiredFields",
"Email address, host, port and password are required"
),
body: t("settingsPage.emailSettings.toastEmailRequiredFieldsError"),
variant: "error",
});
return;

View File

@@ -1,6 +1,6 @@
// Components
import { TabContext } from "@mui/lab";
import { Tab, useTheme } from "@mui/material";
import { Tab } from "@mui/material";
import Settings from "./Settings";
import Content from "./Content";
@@ -22,8 +22,12 @@ const Tabs = ({
tab,
setTab,
TAB_LIST,
handleDelete,
isDeleteOpen,
setIsDeleteOpen,
isDeleting,
isLoading,
}) => {
const theme = useTheme();
return (
<TabContext value={TAB_LIST[tab]}>
<CustomTabList
@@ -32,7 +36,7 @@ const Tabs = ({
}}
aria-label="status page tabs"
>
{TAB_LIST.map((tabLabel, idx) => (
{TAB_LIST.map((tabLabel) => (
<Tab
key={tabLabel}
label={tabLabel}
@@ -50,6 +54,11 @@ const Tabs = ({
removeLogo={removeLogo}
errors={errors}
isCreate={isCreate}
handleDelete={handleDelete}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
isDeleting={isDeleting}
isLoading={isLoading}
/>
) : (
<Content
@@ -67,6 +76,7 @@ const Tabs = ({
};
Tabs.propTypes = {
isCreate: PropTypes.bool,
form: PropTypes.object,
errors: PropTypes.object,
monitors: PropTypes.array,
@@ -79,6 +89,11 @@ Tabs.propTypes = {
tab: PropTypes.number,
setTab: PropTypes.func,
TAB_LIST: PropTypes.array,
handleDelete: PropTypes.func,
isDeleteOpen: PropTypes.bool,
setIsDeleteOpen: PropTypes.func,
isDeleting: PropTypes.bool,
isLoading: PropTypes.bool,
};
export default Tabs;

View File

@@ -3,6 +3,8 @@ import { Stack, Button, Typography } from "@mui/material";
import Tabs from "./Components/Tabs";
import GenericFallback from "../../../Components/GenericFallback";
import SkeletonLayout from "./Components/Skeleton";
import Dialog from "../../../Components/Dialog";
import Breadcrumbs from "../../../Components/Breadcrumbs";
//Utils
import { useTheme } from "@emotion/react";
import { useState, useEffect, useRef, useCallback } from "react";
@@ -15,9 +17,8 @@ import { useNavigate } from "react-router-dom";
import { useStatusPageFetch } from "../Status/Hooks/useStatusPageFetch";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useStatusPageDelete } from "../Status/Hooks/useStatusPageDelete";
//Constants
const TAB_LIST = ["General settings", "Contents"];
const ERROR_TAB_MAPPING = [
["companyName", "url", "timezone", "color", "isPublished", "logo"],
["monitors", "showUptimePercentage", "showCharts", "showAdminLoginLink"],
@@ -28,6 +29,7 @@ const CreateStatusPage = () => {
//Local state
const [tab, setTab] = useState(0);
const [progress, setProgress] = useState({ value: 0, isLoading: false });
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [form, setForm] = useState({
isPublished: false,
companyName: "",
@@ -52,13 +54,13 @@ const CreateStatusPage = () => {
//Utils
const theme = useTheme();
const [monitors, isLoading, networkError] = useMonitorsFetch();
const [createStatusPage, createStatusIsLoading, createStatusPageNetworkError] =
useCreateStatusPage(isCreate);
const [createStatusPage] = useCreateStatusPage(isCreate);
const navigate = useNavigate();
const { t } = useTranslation();
const [statusPage, statusPageMonitors, statusPageIsLoading, statusPageNetworkError] =
const [statusPage, statusPageMonitors, statusPageIsLoading, , fetchStatusPage] =
useStatusPageFetch(isCreate, url);
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
console.log(JSON.stringify(form, null, 2));
// Handlers
@@ -124,6 +126,19 @@ const CreateStatusPage = () => {
setProgress({ value: 0, isLoading: false });
};
/**
* Handle status page deletion with optimistic UI update
* Immediately navigates away without waiting for the deletion to complete
* to prevent unnecessary network requests for the deleted page
*/
const handleDelete = async () => {
setIsDeleteOpen(false);
// Start deletion process but don't wait for it
deleteStatusPage();
// Immediately navigate away to prevent additional fetches for the deleted page
navigate("/status");
};
const handleSubmit = async () => {
let toSubmit = {
...form,
@@ -137,9 +152,7 @@ const CreateStatusPage = () => {
const success = await createStatusPage({ form });
if (success) {
createToast({
body: isCreate
? "Status page created successfully"
: "Status page updated successfully",
body: isCreate ? t("statusPage.createSuccess") : t("statusPage.updateSuccess"),
});
navigate(`/status/uptime/${form.url}`);
}
@@ -162,7 +175,7 @@ const CreateStatusPage = () => {
// If we get -1, there's an unknown error
if (errorTabs[0] === -1) {
createToast({ body: "Unknown error" });
createToast({ body: t("common.toasts.unknownError") });
return;
}
@@ -223,6 +236,37 @@ const CreateStatusPage = () => {
// Load fields
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs
list={[
{ name: t("statusBreadCrumbsStatusPages", "Status"), path: "/status" },
{ name: t("statusBreadCrumbsDetails", "Details"), path: `/status/${url}` },
{ name: t("configure", "Configure"), path: `/status/create/${url}` },
]}
/>
{!isCreate && (
<Stack
direction="row"
justifyContent="flex-end"
>
<Button
loading={isDeleting}
variant="contained"
color="error"
onClick={() => setIsDeleteOpen(true)}
>
{t("remove")}
</Button>
<Dialog
title={t("deleteStatusPage")}
onConfirm={handleDelete}
onCancel={() => setIsDeleteOpen(false)}
open={isDeleteOpen}
confirmationButtonLabel={t("deleteStatusPageConfirm")}
description={t("deleteStatusPageDescription")}
isLoading={isDeleting || statusPageIsLoading}
/>
</Stack>
)}
<Tabs
form={form}
errors={errors}
@@ -235,8 +279,16 @@ const CreateStatusPage = () => {
removeLogo={removeLogo}
tab={tab}
setTab={setTab}
TAB_LIST={TAB_LIST}
TAB_LIST={[
t("statusPage.generalSettings", "General settings"),
t("statusPage.contents", "Contents"),
]}
isCreate={isCreate}
handleDelete={handleDelete}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
isDeleting={isDeleting}
isLoading={statusPageIsLoading}
/>
<Stack
direction="row"

View File

@@ -11,7 +11,7 @@ import { useLocation } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
const Controls = ({ isDeleteOpen, setIsDeleteOpen, isDeleting, url, type }) => {
const Controls = ({ url, type }) => {
const theme = useTheme();
const { t } = useTranslation();
const location = useLocation();
@@ -27,16 +27,6 @@ const Controls = ({ isDeleteOpen, setIsDeleteOpen, isDeleting, url, type }) => {
direction="row"
gap={theme.spacing(2)}
>
<Box>
<Button
variant="contained"
color="error"
onClick={() => setIsDeleteOpen(!isDeleteOpen)}
loading={isDeleting}
>
{t("delete")}
</Button>
</Box>
<Box>
<Button
variant="contained"
@@ -65,21 +55,10 @@ const Controls = ({ isDeleteOpen, setIsDeleteOpen, isDeleting, url, type }) => {
Controls.propTypes = {
type: PropTypes.string,
isDeleting: PropTypes.bool,
url: PropTypes.string,
isDeleteOpen: PropTypes.bool.isRequired,
setIsDeleteOpen: PropTypes.func.isRequired,
};
const ControlsHeader = ({
statusPage,
isPublic,
isDeleting,
isDeleteOpen,
setIsDeleteOpen,
url,
type = "uptime",
}) => {
const ControlsHeader = ({ statusPage, isPublic, url, type = "uptime" }) => {
const theme = useTheme();
const { t } = useTranslation();
const publicUrl = `/status/uptime/public/${url}`;
@@ -137,9 +116,6 @@ const ControlsHeader = ({
)}
</Stack>
<Controls
isDeleting={isDeleting}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
url={url}
type={type}
/>
@@ -152,9 +128,6 @@ ControlsHeader.propTypes = {
url: PropTypes.string,
statusPage: PropTypes.object,
isPublic: PropTypes.bool,
isDeleting: PropTypes.bool,
isDeleteOpen: PropTypes.bool.isRequired,
setIsDeleteOpen: PropTypes.func.isRequired,
type: PropTypes.string,
};

View File

@@ -1,19 +1,35 @@
import { useSelector } from "react-redux";
import { useState } from "react";
import { networkService } from "../../../../main";
import { networkService } from "../../../../Utils/NetworkService";
import { createToast } from "../../../../Utils/toastUtils";
import { useTranslation } from "react-i18next";
/**
* Hook for deleting a status page with optimistic UI update
* @param {Function} fetchStatusPage - Function to fetch status page data
* @param {string} url - URL of the status page
* @returns {Array} - [deleteStatusPage function, isLoading state]
*/
const useStatusPageDelete = (fetchStatusPage, url) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
/**
* Delete a status page with optimistic UI update
* @returns {Promise<boolean>} - Success status
*/
const deleteStatusPage = async () => {
// We don't need to call fetchStatusPage after deletion
// This prevents the 404 error when trying to fetch a deleted status page
try {
setIsLoading(true);
await networkService.deleteStatusPage({ url });
fetchStatusPage?.();
createToast({
body: t("statusPage.deleteSuccess", "Status page deleted successfully"),
});
return true;
} catch (error) {
createToast({
body: error.message,
body: t("statusPage.deleteFailed", "Failed to delete status page"),
});
return false;
} finally {

View File

@@ -6,7 +6,6 @@ import ControlsHeader from "./Components/ControlsHeader";
import SkeletonLayout from "./Components/Skeleton";
import StatusBar from "./Components/StatusBar";
import MonitorsList from "./Components/MonitorsList";
import Dialog from "../../../Components/Dialog";
import Breadcrumbs from "../../../Components/Breadcrumbs/index.jsx";
import TextLink from "../../../Components/TextLink";
@@ -15,27 +14,19 @@ import { useStatusPageFetch } from "./Hooks/useStatusPageFetch";
import { useTheme } from "@emotion/react";
import { useIsAdmin } from "../../../Hooks/useIsAdmin";
import { useLocation } from "react-router-dom";
import { useStatusPageDelete } from "./Hooks/useStatusPageDelete";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
const PublicStatus = () => {
const { url } = useParams();
// Local state
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
// Utils
const theme = useTheme();
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const [statusPage, monitors, isLoading, networkError, fetchStatusPage] =
useStatusPageFetch(false, url);
const [deleteStatusPage, isDeleting] = useStatusPageDelete(fetchStatusPage, url);
const [statusPage, monitors, isLoading, networkError] = useStatusPageFetch(false, url);
// Breadcrumbs
const crumbs = [
@@ -158,9 +149,6 @@ const PublicStatus = () => {
{!isPublic && <Breadcrumbs list={crumbs} />}
<ControlsHeader
statusPage={statusPage}
isDeleting={isDeleting}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
url={url}
isPublic={isPublic}
/>
@@ -168,21 +156,6 @@ const PublicStatus = () => {
<StatusBar monitors={monitors} />
<MonitorsList monitors={monitors} />
{link}
<Dialog
title={t("deleteStatusPage")}
onConfirm={() => {
deleteStatusPage();
setIsDeleteOpen(false);
navigate("/status");
}}
onCancel={() => {
setIsDeleteOpen(false);
}}
open={isDeleteOpen}
confirmationButtonLabel={t("deleteStatusPageConfirm")}
description={t("deleteStatusPageDescription")}
isLoading={isDeleting || isLoading}
/>
</Stack>
);
};

View File

@@ -19,21 +19,21 @@ const StatusBox = ({ title, value, status }) => {
if (status === "up") {
color = theme.palette.success.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: 8 }}>
<Box sx={{ ...sharedStyles, top: theme.spacing(4) }}>
<Arrow />
</Box>
);
} else if (status === "down") {
color = theme.palette.error.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: 5 }}>
<Box sx={{ ...sharedStyles, transform: "rotate(180deg)", top: theme.spacing(2) }}>
<Arrow />
</Box>
);
} else if (status === "paused") {
color = theme.palette.warning.lowContrast;
icon = (
<Box sx={{ ...sharedStyles, top: 12, right: 12 }}>
<Box sx={{ ...sharedStyles, top: theme.spacing(6), right: theme.spacing(6) }}>
<ClockSnooze />
</Box>
);
@@ -75,15 +75,15 @@ const StatusBox = ({ title, value, status }) => {
<Stack
direction="row"
alignItems="flex-start"
fontSize={36}
fontSize={theme.typography.h1.fontSize}
fontWeight={600}
color={color}
gap="2px"
gap={theme.spacing(1)}
>
{value}
<Typography
fontSize={20}
fontSize={theme.typography.label.fontSize}
fontWeight={300}
color={theme.palette.primary.contrastTextSecondary}
sx={{

View File

@@ -601,6 +601,18 @@ class NetworkService {
return this.axiosInstance.get(`/checks/team?${params.toString()}`);
};
/**
* ************************************
* Get summary of checks by team ID
* ************************************
*
* @async
* @returns {Promise<AxiosResponse>} The response from the axios GET request.
*/
getChecksAndSummaryByTeamId = async () => {
return this.axiosInstance.get(`/checks/team/summary`);
};
/**
* ************************************
* Update the status of a check
@@ -619,6 +631,12 @@ class NetworkService {
});
}
async updateAllChecksStatus(config) {
return this.axiosInstance.put(`/checks/team/`, {
ack: config.ack,
});
}
/**
* ************************************
* Get all checks for a given user

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -197,12 +197,14 @@
"basicInformation": "Basic Information",
"bulkImport": {
"fallbackPage": "Import a file to upload a list of servers in bulk",
"invalidFileType": "Invalid file type",
"noFileSelected": "No file selected",
"parsingFailed": "Parsing failed",
"selectFile": "Select File",
"selectFileDescription": "You can download our <template>template</template> or <sample>sample</sample>",
"selectFileTips": "Select CSV file to upload",
"title": "Bulk Import",
"uploadFailed": "Upload failed",
"uploadSuccess": "Monitors created successfully!",
"validationFailed": "Validation failed"
},
@@ -226,6 +228,7 @@
"commonSaving": "Saving...",
"companyName": "Company name",
"configure": "Configure",
"confirmPassword": "Confirm password",
"cores": "Cores",
"country": "COUNTRY",
"cpu": "CPU",
@@ -385,8 +388,12 @@
"incidentsOptionsHeader": "Incidents for:",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderFilterBy": "Filter by:",
"incidentsOptionsHeaderFilterCannotResolve": "Cannot resolve",
"incidentsOptionsHeaderTotalIncidents": "Total Incidents",
"incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve",
"incidentsTableResolvedAt": "Resolved at",
"incidentsOptionsHeaderFilterDown": "Down",
"incidentsTableActionResolve": "Resolve",
"incidentsPageActionResolve": "Resolve all incidents",
"incidentsOptionsHeaderFilterResolved": "Resolved",
"incidentsOptionsHeaderLastDay": "Last day",
"incidentsOptionsHeaderLastHour": "Last hour",
@@ -488,6 +495,11 @@
"title": "Export/Import"
},
"monitorDisplayName": "Monitor display name",
"monitorHooks": {
"failureAddDemoMonitors": "Failed to add demo monitors",
"successAddDemoMonitors": "Successfully added demo monitors"
},
"monitorState": {
"active": "Active",
"paused": "Paused",
@@ -504,6 +516,10 @@
"monitorStatusUp": "Monitor {name} ({url}) is now UP and responding",
"monitors": "monitors",
"monitorsToApply": "Monitors to apply maintenance window to",
"checkHooks": {
"failureResolveOne": "Failed to resolve incident.",
"failureResolveAll": "Failed to resolve all incidents."
},
"ms": "ms",
"navControls": "Controls",
"nextWindow": "Next window",
@@ -661,12 +677,14 @@
"selectAll": "Select all",
"sendTestNotifications": "Send test notifications",
"seperateEmails": "You can separate multiple emails with a comma",
"settingsAppearance": "Appearance",
"settingsDisabled": "Disabled",
"settingsDisplayTimezone": "Display timezone",
"settingsEmailUser": "Email user - Username for authentication, overrides email address if specified",
"settingsFailedToAddDemoMonitors": "Failed to add demo monitors",
"settingsFailedToClearStats": "Failed to clear stats",
"settingsFailedToDeleteMonitors": "Failed to delete all monitors",
"settingsFailedToSave": "Failed to save settings",
"settingsGeneralSettings": "General settings",
"settingsMonitorsDeleted": "Successfully deleted all monitors",
"settingsPage": {
"aboutSettings": {
@@ -698,7 +716,8 @@
"labelUser": "Email user - Username for authentication, overrides email address if specified",
"linkTransport": "See specifications here",
"placeholderUser": "Leave empty if not required",
"title": "Email"
"title": "Email",
"toastEmailRequiredFieldsError": "Email address, host, port and password are required"
},
"pageSpeedSettings": {
"description": "Enter your Google PageSpeed API key to enable Google PageSpeed monitoring. Click Reset to update the key.",
@@ -790,6 +809,14 @@
"statusPageStatusNoPage": "There's no status page here.",
"statusPageStatusNotPublic": "This status page is not public.",
"statusPageStatusServiceStatus": "Service status",
"statusPage": {
"deleteSuccess": "Status page deleted successfully",
"deleteFailed": "Failed to delete status page",
"createSuccess": "Status page created successfully",
"updateSuccess": "Status page updated successfully",
"generalSettings": "General settings",
"contents": "Contents"
},
"submit": "Submit",
"teamPanel": {
"cancel": "Cancel",
@@ -815,6 +842,7 @@
"teamMembers": "Team members"
},
"testLocale": "testLocale",
"testNotificationsDisabled": "There are no notifications setup for this monitor. You need to add one by clicking 'Configure' button",
"timeZoneInfo": "All dates and times are in GMT+0 time zone.",
"timezone": "Timezone",
"title": "Title",

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "Bildirimleri dene",
"testNotificationsDisabled": "",
"selectAll": "Tümünü seç",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -690,6 +690,7 @@
},
"advancedMatching": "",
"sendTestNotifications": "",
"testNotificationsDisabled": "",
"selectAll": "",
"showAdminLoginLink": "",
"logsPage": {

View File

@@ -111,6 +111,19 @@ class CheckController {
}
};
getChecksSummaryByTeamId = async (req, res, next) => {
try {
const { teamId } = req.user;
const summary = await this.db.getChecksSummaryByTeamId({ teamId });
return res.success({
msg: this.stringService.checkGetSummary,
data: summary,
});
} catch (error) {
next(handleError(error, SERVICE_NAME, "getChecksSummaryByTeamId"));
}
};
ackCheck = async (req, res, next) => {
try {
await ackCheckBodyValidation.validateAsync(req.body);

View File

@@ -5,6 +5,7 @@ import PageSpeedCheck from "../../models/PageSpeedCheck.js";
import User from "../../models/User.js";
import logger from "../../../utils/logger.js";
import { ObjectId } from "mongodb";
import { buildChecksSummaryByTeamIdPipeline } from "./checkModuleQueries.js";
const SERVICE_NAME = "checkModule";
const dateRangeLookup = {
@@ -304,6 +305,29 @@ const ackAllChecks = async (monitorId, teamId, ack, path) => {
}
};
/**
* Get checks and summary by team ID
* @async
* @param {string} teamId
* @returns {Promise<Object>}
* @throws {Error}
*/
const getChecksSummaryByTeamId = async ({ teamId }) => {
try {
const matchStage = {
teamId: new ObjectId(teamId),
};
const checks = await Check.aggregate(
buildChecksSummaryByTeamIdPipeline({ matchStage })
);
return checks[0].summary;
} catch (error) {
error.service = SERVICE_NAME;
error.method = "getChecksSummaryByTeamId";
throw error;
}
};
/**
* Delete all checks for a monitor
* @async
@@ -387,6 +411,7 @@ export {
getChecksByTeam,
ackCheck,
ackAllChecks,
getChecksSummaryByTeamId,
deleteChecks,
deleteChecksByTeamId,
updateChecksTTL,

View File

@@ -0,0 +1,52 @@
const buildChecksSummaryByTeamIdPipeline = ({ matchStage }) => {
return [
{ $match: matchStage },
{
$facet: {
summary: [
{
$group: {
_id: null,
totalChecks: { $sum: { $cond: [{ $eq: ["$status", false] }, 1, 0] } },
resolvedChecks: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$ack", true] }, { $eq: ["$status", false] }] },
1,
0,
],
},
},
downChecks: {
$sum: {
$cond: [
{ $and: [{ $eq: ["$ack", false] }, { $eq: ["$status", false] }] },
1,
0,
],
},
},
cannotResolveChecks: {
$sum: {
$cond: [{ $eq: ["$statusCode", 5000] }, 1, 0],
},
},
},
},
{
$project: {
_id: 0,
},
},
],
},
},
{
$project: {
summary: { $arrayElemAt: ["$summary", 0] },
},
},
];
};
export { buildChecksSummaryByTeamIdPipeline };

View File

@@ -14,6 +14,7 @@ class CheckRoutes {
initRoutes() {
this.router.get("/team", this.checkController.getChecksByTeam);
this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId);
this.router.delete(
"/team",
isAllowed(["admin", "superadmin"]),