details barebones
This commit is contained in:
1 parent
a4d818e0c5
commit
4f4863db75
13 files changed
+721
-7
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import ms from "ms";
|
||||
import { useMemo } from "react";
|
||||
import type { IMonitor } from "@/types/monitor";
|
||||
import humanInterval from "human-interval";
|
||||
import type { FormValues, SubmitValues } from "@/pages/docker/DockerForm";
|
||||
|
||||
export const useInitDockerForm = ({
|
||||
initialData,
|
||||
}: {
|
||||
initialData: IMonitor | undefined;
|
||||
}) => {
|
||||
return useMemo(() => {
|
||||
const apiToForm = (apiData: IMonitor): FormValues => {
|
||||
return {
|
||||
type: apiData.type,
|
||||
url: apiData.url,
|
||||
secret: apiData.secret,
|
||||
n: apiData.n,
|
||||
notificationChannels: apiData.notificationChannels,
|
||||
name: apiData.name,
|
||||
interval: ms(apiData.interval, { long: true }),
|
||||
rejectUnauthorized: apiData.rejectUnauthorized,
|
||||
};
|
||||
};
|
||||
|
||||
const formToApi = (formData: FormValues): SubmitValues => {
|
||||
const value = humanInterval(formData.interval);
|
||||
return {
|
||||
type: formData.type,
|
||||
url: formData.url,
|
||||
secret: formData.secret,
|
||||
n: formData.n,
|
||||
notificationChannels: formData.notificationChannels,
|
||||
name: formData.name,
|
||||
interval: typeof value === "number" ? value : undefined,
|
||||
rejectUnauthorized: formData.rejectUnauthorized,
|
||||
};
|
||||
};
|
||||
|
||||
const defaults: FormValues = initialData
|
||||
? apiToForm(initialData)
|
||||
: {
|
||||
type: "docker",
|
||||
url: "",
|
||||
secret: "",
|
||||
n: 3,
|
||||
notificationChannels: [],
|
||||
name: "",
|
||||
interval: "1 minute",
|
||||
rejectUnauthorized: true,
|
||||
};
|
||||
|
||||
return { defaults, apiToForm, formToApi };
|
||||
}, [initialData]);
|
||||
};
|
||||
@@ -525,6 +525,11 @@
|
||||
"Provide visibility into what is running on each Docker host"
|
||||
],
|
||||
"title": "A Docker monitor is used to:"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"runningContainers": "Running containers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"infrastructure": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useGet, usePatch } from "@/hooks/UseApi";
|
||||
import DockerForm from "@/pages/docker/DockerForm";
|
||||
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import type { ApiResponse } from "@/types/api";
|
||||
import type { INotificationChannel } from "@/types/notification-channel";
|
||||
import type { IMonitor } from "@/types/monitor";
|
||||
import type { SubmitValues } from "@/pages/docker/DockerForm";
|
||||
|
||||
const DockerConfigurePage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { response } = useGet<ApiResponse<INotificationChannel[]>>(
|
||||
"/notification-channels"
|
||||
);
|
||||
const { response: monitorResponse } = useGet<ApiResponse<IMonitor>>(
|
||||
`/monitors/${id}`
|
||||
);
|
||||
const monitor = monitorResponse?.data || null;
|
||||
const notificationOptions = response?.data ?? [];
|
||||
|
||||
const { patch, loading, error } = usePatch<Partial<SubmitValues>, IMonitor>();
|
||||
|
||||
if (!monitor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: SubmitValues) => {
|
||||
const result = await patch(`/monitors/${id}`, data);
|
||||
if (result) {
|
||||
navigate(`/docker/${id}`);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<DockerForm
|
||||
mode="configure"
|
||||
initialData={{
|
||||
...monitor,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
notificationOptions={notificationOptions}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerConfigurePage;
|
||||
@@ -0,0 +1,34 @@
|
||||
import DockerForm, { type SubmitValues } from "@/pages/docker/DockerForm";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useGet, usePost } from "@/hooks/UseApi";
|
||||
import type { ApiResponse } from "@/types/api";
|
||||
import type { INotificationChannel } from "@/types/notification-channel";
|
||||
|
||||
const DockerCreatePage = () => {
|
||||
const { response } = useGet<ApiResponse<INotificationChannel[]>>(
|
||||
"/notification-channels"
|
||||
);
|
||||
const { post, loading, error } = usePost<SubmitValues, any>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (data: SubmitValues) => {
|
||||
const result = await post("/monitors", data);
|
||||
if (result) {
|
||||
navigate("/docker");
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const notificationOptions = response?.data ?? [];
|
||||
return (
|
||||
<DockerForm
|
||||
onSubmit={onSubmit}
|
||||
notificationOptions={notificationOptions}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerCreatePage;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { BasePage, StatBox } from "@/components/design-elements";
|
||||
import { HeaderControls, HeaderRange } from "@/components/monitors";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import prettyMilliseconds from "pretty-ms";
|
||||
import { useGet, usePatch } from "@/hooks/UseApi";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import type { ApiResponse } from "@/types/api";
|
||||
import type { IMonitor, IMonitorWithMonitorStats } from "@/types/monitor";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { getStatusPalette } from "@/utils/MonitorUtils";
|
||||
import { config } from "@/config/index";
|
||||
const GLOBAL_REFRESH = config.GLOBAL_REFRESH;
|
||||
|
||||
const DockerDetailsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { id } = useParams();
|
||||
const [range, setRange] = useState("1h");
|
||||
|
||||
const { response, loading, refetch } = useGet<
|
||||
ApiResponse<IMonitorWithMonitorStats>
|
||||
>(
|
||||
`/monitors/${id}?embedChecks=true&range=${range}`,
|
||||
{},
|
||||
{ refreshInterval: GLOBAL_REFRESH, keepPreviousData: true }
|
||||
);
|
||||
|
||||
const { patch, loading: isPatching } = usePatch<any, IMonitor>();
|
||||
|
||||
const monitor = response?.data?.monitor;
|
||||
const stats = response?.data?.stats;
|
||||
const checks = (response?.data?.checks || []) as any;
|
||||
const latestCheck = checks[0];
|
||||
const palette = getStatusPalette(monitor?.status || "initializing");
|
||||
|
||||
const streakDuration = stats?.currentStreakStartedAt
|
||||
? Date.now() - stats?.currentStreakStartedAt
|
||||
: 0;
|
||||
|
||||
const lastChecked = stats?.lastCheckTimestamp
|
||||
? Date.now() - stats?.lastCheckTimestamp
|
||||
: -1;
|
||||
|
||||
if (!monitor) return null;
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<HeaderControls
|
||||
monitor={monitor}
|
||||
patch={patch}
|
||||
isPatching={isPatching}
|
||||
refetch={refetch}
|
||||
path="/docker"
|
||||
/>
|
||||
<Stack direction="row" gap={theme.spacing(8)} flexWrap={"wrap"}>
|
||||
<StatBox
|
||||
palette={palette}
|
||||
title={t("monitors.common.stats.activeFor")}
|
||||
subtitle={prettyMilliseconds(streakDuration, {
|
||||
secondsDecimalDigits: 0,
|
||||
})}
|
||||
/>
|
||||
<StatBox
|
||||
title={t("monitors.common.stats.lastCheck")}
|
||||
subtitle={
|
||||
lastChecked >= 0
|
||||
? `${prettyMilliseconds(lastChecked, {
|
||||
secondsDecimalDigits: 0,
|
||||
})} ago`
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
<HeaderRange loading={loading} range={range} setRange={setRange} />
|
||||
<Stack gap={theme.spacing(6)}>
|
||||
<Box component="pre" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{JSON.stringify(latestCheck, null, 2)}
|
||||
</Box>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerDetailsPage;
|
||||
@@ -0,0 +1,240 @@
|
||||
import Stack from "@mui/material/Stack";
|
||||
import { TextInput, Button, AutoComplete } from "@/components/inputs";
|
||||
import { ConfigBox, BasePage } from "@/components/design-elements";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { monitorSchemaDocker } from "@/validation";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
useForm,
|
||||
Controller,
|
||||
useWatch,
|
||||
type SubmitHandler,
|
||||
} from "react-hook-form";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useInitDockerForm } from "@/hooks/forms/UseInitDockerForm";
|
||||
import type { IMonitor } from "@/types/monitor";
|
||||
|
||||
export type FormValues = z.infer<typeof monitorSchemaDocker>;
|
||||
export type SubmitValues = Omit<FormValues, "interval"> & {
|
||||
interval: number | undefined;
|
||||
};
|
||||
|
||||
export const DockerForm = ({
|
||||
mode = "create",
|
||||
initialData,
|
||||
onSubmit,
|
||||
notificationOptions,
|
||||
loading,
|
||||
}: {
|
||||
mode?: "create" | "configure";
|
||||
initialData?: IMonitor;
|
||||
onSubmit: SubmitHandler<SubmitValues>;
|
||||
notificationOptions: any[];
|
||||
loading: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const { defaults, formToApi } = useInitDockerForm({ initialData });
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(monitorSchemaDocker) as any,
|
||||
defaultValues: defaults as any,
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaults as any);
|
||||
}, [initialData, reset, defaults]);
|
||||
|
||||
const notificationChannels = useWatch({
|
||||
control,
|
||||
name: "notificationChannels",
|
||||
});
|
||||
|
||||
const submitForm = (formData: FormValues) => {
|
||||
onSubmit(formToApi(formData) as any);
|
||||
};
|
||||
|
||||
return (
|
||||
<BasePage component={"form"} onSubmit={handleSubmit(submitForm)}>
|
||||
<ConfigBox
|
||||
title={t("monitors.common.form.general.title")}
|
||||
subtitle={t(`monitors.infrastructure.form.general.description`)}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(8)}>
|
||||
<Controller
|
||||
disabled={mode === "configure"}
|
||||
name="url"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("monitors.common.form.optionUrl")}
|
||||
fullWidth
|
||||
error={!!errors.url}
|
||||
helperText={errors.url ? errors.url.message : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("monitors.common.form.optionDisplayName")}
|
||||
fullWidth
|
||||
error={!!errors.name}
|
||||
helperText={errors.name ? errors.name.message : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="secret"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t(
|
||||
"monitors.infrastructure.form.general.optionSecret"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!errors.secret}
|
||||
helperText={errors.secret ? errors.secret.message : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("monitors.common.form.incidents.title")}
|
||||
subtitle={t("monitors.common.form.incidents.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="n"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="number"
|
||||
fieldLabel={t(
|
||||
"monitors.common.form.incidents.optionIncidentCount"
|
||||
)}
|
||||
fullWidth
|
||||
error={!!errors.n}
|
||||
helperText={errors.n ? errors.n.message : ""}
|
||||
onChange={(e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
field.onChange(target.valueAsNumber);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("monitors.common.form.notifications.title")}
|
||||
subtitle={t("monitors.common.form.notifications.description")}
|
||||
rightContent={
|
||||
<Stack>
|
||||
<Controller
|
||||
name="notificationChannels"
|
||||
control={control}
|
||||
defaultValue={[]}
|
||||
render={({ field }) => (
|
||||
<AutoComplete
|
||||
multiple
|
||||
options={notificationOptions}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={notificationOptions.filter((o: any) =>
|
||||
(field.value || []).includes(o._id)
|
||||
)}
|
||||
onChange={(_, newValue) => {
|
||||
field.onChange(newValue.map((o: any) => o._id));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Stack gap={theme.spacing(2)} mt={theme.spacing(2)}>
|
||||
{notificationChannels.map((notificationId: string) => {
|
||||
const option = notificationOptions.find(
|
||||
(o: any) => o._id === notificationId
|
||||
);
|
||||
if (!option) return null;
|
||||
return (
|
||||
<Stack
|
||||
width={"100%"}
|
||||
justifyContent={"space-between"}
|
||||
direction="row"
|
||||
key={notificationId}
|
||||
>
|
||||
<Typography>{option.name}</Typography>
|
||||
<Trash2
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
onClick={() => {
|
||||
const updated = notificationChannels.filter(
|
||||
(id: string) => id !== notificationId
|
||||
);
|
||||
setValue("notificationChannels", updated);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("monitors.common.form.interval.title")}
|
||||
subtitle={t("monitors.common.form.interval.description")}
|
||||
rightContent={
|
||||
<Controller
|
||||
name="interval"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
type="text"
|
||||
fieldLabel={t("monitors.common.form.interval.optionInterval")}
|
||||
fullWidth
|
||||
error={!!errors.interval}
|
||||
helperText={errors.interval ? errors.interval.message : ""}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" justifyContent="flex-end">
|
||||
<Button
|
||||
loading={loading}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DockerForm;
|
||||
@@ -107,6 +107,8 @@ const DockerMonitorsPage = () => {
|
||||
</Stack>
|
||||
<DockerMonitorsTable
|
||||
monitors={monitors}
|
||||
refetch={refetch}
|
||||
setSelectedMonitor={setSelectedMonitor}
|
||||
count={count}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Table, Pagination, StatusLabel } from "@/components/design-elements";
|
||||
import { ActionsMenu } from "@/components/actions-menu";
|
||||
import { usePatch } from "@/hooks/UseApi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import type { Header } from "@/components/design-elements/Table";
|
||||
import type { IMonitor } from "@/types/monitor";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,6 +15,8 @@ export const DockerMonitorsTable = ({
|
||||
setPage,
|
||||
rowsPerPage,
|
||||
setRowsPerPage,
|
||||
refetch,
|
||||
setSelectedMonitor,
|
||||
}: {
|
||||
monitors: IMonitor[];
|
||||
count: number;
|
||||
@@ -18,9 +24,14 @@ export const DockerMonitorsTable = ({
|
||||
setPage: (page: number) => void;
|
||||
rowsPerPage: number;
|
||||
setRowsPerPage: (rowsPerPage: number) => void;
|
||||
refetch: Function;
|
||||
setSelectedMonitor: Function;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { patch } = usePatch<any, IMonitor>();
|
||||
console.log(monitors);
|
||||
const handlePageChange = (
|
||||
_e: React.MouseEvent<HTMLButtonElement> | null,
|
||||
newPage: number
|
||||
@@ -33,11 +44,59 @@ export const DockerMonitorsTable = ({
|
||||
setRowsPerPage(Number(e.target.value));
|
||||
};
|
||||
|
||||
const getActions = (monitor: IMonitor) => [
|
||||
{
|
||||
id: 1,
|
||||
label: t("monitors.common.actions.openSite"),
|
||||
action: () => window.open(monitor.url, "_blank", "noreferrer"),
|
||||
closeMenu: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: t("monitors.common.actions.details"),
|
||||
action: () => navigate(`/docker/${monitor._id}`),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: t("monitors.common.actions.incidents"),
|
||||
action: () => navigate(`/incidents?monitorId=${monitor._id}`),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: t("monitors.common.actions.configure"),
|
||||
action: () => navigate(`/docker/${monitor._id}/configure`),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label:
|
||||
monitor.status === "paused"
|
||||
? t("common.buttons.resume")
|
||||
: t("common.buttons.pause"),
|
||||
action: async () => {
|
||||
await patch(`/monitors/${monitor._id}/active`);
|
||||
refetch();
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
label: (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("common.buttons.delete")}
|
||||
</Typography>
|
||||
),
|
||||
action: () => setSelectedMonitor(monitor),
|
||||
closeMenu: true,
|
||||
},
|
||||
];
|
||||
|
||||
const headers: Header<IMonitor>[] = [
|
||||
{
|
||||
id: "name",
|
||||
content: (
|
||||
<Typography component="div">{t("common.table.headers.name")}</Typography>
|
||||
<Typography component="div">
|
||||
{t("common.table.headers.name")}
|
||||
</Typography>
|
||||
),
|
||||
render: (row) => row.name,
|
||||
},
|
||||
@@ -50,11 +109,33 @@ export const DockerMonitorsTable = ({
|
||||
),
|
||||
render: (row) => <StatusLabel status={row.status} />,
|
||||
},
|
||||
{
|
||||
id: "runningContainers",
|
||||
content: (
|
||||
<Typography component="div">
|
||||
{t("monitors.docker.table.headers.runningContainers")}
|
||||
</Typography>
|
||||
),
|
||||
render: (row) => {
|
||||
const containerCount = (row.latestChecks?.[0]?.dockerContainers || [])
|
||||
.length;
|
||||
return containerCount;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
content: <Typography component="div">{t("common.table.headers.actions")}</Typography>,
|
||||
render: (row) => <ActionsMenu items={getActions(row)} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table headers={headers} data={monitors} />
|
||||
<Table
|
||||
headers={headers}
|
||||
data={monitors}
|
||||
onRowClick={(row) => navigate(`/docker/${row._id}`)}
|
||||
/>
|
||||
<Pagination
|
||||
component="div"
|
||||
count={count}
|
||||
|
||||
@@ -32,6 +32,9 @@ import InfraDetailsPage from "@/pages/infrastructure/InfraDetails";
|
||||
import InfraConfigurePage from "@/pages/infrastructure/InfraConfig";
|
||||
import InfraCreatePage from "@/pages/infrastructure/InfraCreate";
|
||||
import DockerMonitorsPage from "@/pages/docker/DockerMonitors";
|
||||
import DockerCreatePage from "@/pages/docker/DockerCreate";
|
||||
import DockerConfigurePage from "@/pages/docker/DockerConfig";
|
||||
import DockerDetailsPage from "@/pages/docker/DockerDetails";
|
||||
|
||||
import MaintenancePage from "@/pages/maintenance/Maintenance";
|
||||
import MaintenanceConfigPage from "@/pages/maintenance/MaintenanceConfig";
|
||||
@@ -138,6 +141,9 @@ const Router = () => {
|
||||
/>
|
||||
<Route path="infrastructure/:id" element={<InfraDetailsPage />} />
|
||||
<Route path="infrastructure" element={<InfraMonitorsPage />} />
|
||||
<Route path="docker/create" element={<DockerCreatePage />} />
|
||||
<Route path="docker/:id" element={<DockerDetailsPage />} />
|
||||
<Route path="docker/:id/configure" element={<DockerConfigurePage />} />
|
||||
<Route path="docker" element={<DockerMonitorsPage />} />
|
||||
|
||||
<Route path="maintenance" element={<MaintenancePage />} />
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface ICheck {
|
||||
timings: CheckTimings;
|
||||
lighthouse?: ICheckLighthouseFields;
|
||||
system?: ISystemInfo;
|
||||
// Present for docker monitor checks
|
||||
dockerContainers?: IDockerContainerSummary[];
|
||||
}
|
||||
export interface CheckWithMonitor {
|
||||
_id: string;
|
||||
@@ -173,7 +175,42 @@ export interface IInfraCheck {
|
||||
net: INetInfo[];
|
||||
}
|
||||
|
||||
export interface IDokcerCheck {
|
||||
_id: string;
|
||||
avgResponseTime: number;
|
||||
avgRespponseTimeUp: number;
|
||||
avgResponseTimeDown: number;
|
||||
count: number;
|
||||
downChecks: number;
|
||||
healthyContainers: number;
|
||||
runningContainers: number;
|
||||
totalContainers: number;
|
||||
upChecks: number;
|
||||
}
|
||||
|
||||
export interface IChecksWithCount {
|
||||
checks: ICheck[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Docker types (used by docker monitor checks)
|
||||
export interface IDockerContainerHealth {
|
||||
healthy: boolean;
|
||||
source?: string;
|
||||
message?: string;
|
||||
}
|
||||
export interface IDockerExposedPort {
|
||||
port: string;
|
||||
protocol: string;
|
||||
}
|
||||
export interface IDockerContainerSummary {
|
||||
container_id: string;
|
||||
container_name: string;
|
||||
status: string;
|
||||
running: boolean;
|
||||
base_image: string;
|
||||
exposed_ports: IDockerExposedPort[];
|
||||
started_at: number;
|
||||
finished_at: number;
|
||||
health: IDockerContainerHealth;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { urlRegex, durationSchema } from "@/validation/utils";
|
||||
|
||||
export const monitorSchemaDocker = z.object({
|
||||
type: z.string().min(1, "You must select an option"),
|
||||
url: z.string().min(1, "URL is required").regex(urlRegex, "Invalid URL"),
|
||||
secret: z.string().min(1, "Secret is required"),
|
||||
n: z.coerce
|
||||
.number({ message: "Number required" })
|
||||
.min(1, "Minimum value is 1")
|
||||
.max(25, "Maximum value is 25"),
|
||||
notificationChannels: z.array(z.string()).optional().default([]),
|
||||
name: z.string().min(1, "Display name is required"),
|
||||
interval: durationSchema(60000),
|
||||
rejectUnauthorized: z.boolean().default(true),
|
||||
});
|
||||
@@ -13,3 +13,4 @@ export * from "./invite";
|
||||
export * from "./recovery";
|
||||
export * from "./reset";
|
||||
export * from "./profile";
|
||||
export * from "./docker";
|
||||
@@ -191,7 +191,9 @@ class MonitorService implements IMonitorService {
|
||||
|
||||
if (
|
||||
type.length === 1 &&
|
||||
(type[0] === "pagespeed" || type[0] === "infrastructure")
|
||||
(type[0] === "pagespeed" ||
|
||||
type[0] === "infrastructure" ||
|
||||
type[0] === "docker")
|
||||
) {
|
||||
const monitorIds = monitors.map((m) => m._id);
|
||||
|
||||
@@ -330,6 +332,65 @@ class MonitorService implements IMonitorService {
|
||||
return projectStage;
|
||||
};
|
||||
|
||||
private getDockerGroup = (): Record<string, any> => {
|
||||
return {
|
||||
_id: { $dateTrunc: { date: "$createdAt", unit: "minute" } },
|
||||
count: { $sum: 1 },
|
||||
avgResponseTime: { $last: "$responseTime" },
|
||||
totalContainers: {
|
||||
$last: { $size: { $ifNull: ["$dockerContainers", []] } },
|
||||
},
|
||||
runningContainers: {
|
||||
$last: {
|
||||
$size: {
|
||||
$filter: {
|
||||
input: { $ifNull: ["$dockerContainers", []] },
|
||||
as: "c",
|
||||
cond: { $eq: ["$$c.running", true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
healthyContainers: {
|
||||
$last: {
|
||||
$size: {
|
||||
$filter: {
|
||||
input: { $ifNull: ["$dockerContainers", []] },
|
||||
as: "c",
|
||||
cond: { $eq: ["$$c.health.healthy", true] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runningContainersSnapshot: {
|
||||
$last: {
|
||||
$map: {
|
||||
input: {
|
||||
$filter: {
|
||||
input: { $ifNull: ["$dockerContainers", []] },
|
||||
as: "c",
|
||||
cond: { $eq: ["$$c.running", true] },
|
||||
},
|
||||
},
|
||||
as: "c",
|
||||
in: {
|
||||
container_id: "$$c.container_id",
|
||||
container_name: "$$c.container_name",
|
||||
base_image: "$$c.base_image",
|
||||
exposed_ports: "$$c.exposed_ports",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
private getDockerProjection = (): object => {
|
||||
const projectStage: any = { status: 1, responseTime: 1, createdAt: 1 };
|
||||
projectStage["dockerContainers"] = 1;
|
||||
return projectStage;
|
||||
};
|
||||
|
||||
private getFinalProjection = (type: string): object => {
|
||||
if (type === "pagespeed") {
|
||||
return {
|
||||
@@ -483,6 +544,33 @@ class MonitorService implements IMonitorService {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (type === "docker") {
|
||||
return {
|
||||
_id: 1,
|
||||
count: 1,
|
||||
avgResponseTime: 1,
|
||||
totalContainers: "$totalContainers",
|
||||
runningContainers: "$runningContainers",
|
||||
healthyContainers: "$healthyContainers",
|
||||
runningContainersSnapshot: "$runningContainersSnapshot",
|
||||
totalExposedPorts: "$totalExposedPorts",
|
||||
uniqueImages: "$uniqueImages",
|
||||
runningPercent: {
|
||||
$cond: [
|
||||
{ $gt: ["$totalContainers", 0] },
|
||||
{ $multiply: [{ $divide: ["$runningContainers", "$totalContainers"] }, 100] },
|
||||
null,
|
||||
],
|
||||
},
|
||||
healthyPercent: {
|
||||
$cond: [
|
||||
{ $gt: ["$totalContainers", 0] },
|
||||
{ $multiply: [{ $divide: ["$healthyContainers", "$totalContainers"] }, 100] },
|
||||
null,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
@@ -501,6 +589,8 @@ class MonitorService implements IMonitorService {
|
||||
groupClause = this.getPageSpeedGroup();
|
||||
} else if (monitor.type === "infrastructure") {
|
||||
groupClause = this.getInfraGroup();
|
||||
} else if (monitor.type === "docker") {
|
||||
groupClause = this.getDockerGroup();
|
||||
} else {
|
||||
groupClause = this.getBaseGroup();
|
||||
}
|
||||
@@ -510,12 +600,18 @@ class MonitorService implements IMonitorService {
|
||||
projectStage = this.getPageSpeedProjection();
|
||||
} else if (monitor.type === "infrastructure") {
|
||||
projectStage = this.getInfraProjection();
|
||||
} else if (monitor.type === "docker") {
|
||||
projectStage = this.getDockerProjection();
|
||||
} else {
|
||||
projectStage = this.getBaseProjection();
|
||||
}
|
||||
|
||||
let finalProjection = {};
|
||||
if (monitor.type === "pagespeed" || monitor.type === "infrastructure") {
|
||||
if (
|
||||
monitor.type === "pagespeed" ||
|
||||
monitor.type === "infrastructure" ||
|
||||
monitor.type === "docker"
|
||||
) {
|
||||
finalProjection = this.getFinalProjection(monitor.type);
|
||||
} else {
|
||||
finalProjection = { _id: 1, count: 1, avgResponseTime: 1 };
|
||||
@@ -526,8 +622,8 @@ class MonitorService implements IMonitorService {
|
||||
{ $project: { ...projectStage, status: 1 } },
|
||||
];
|
||||
|
||||
// For infrastructure monitors we rely on $last in group, which requires ordering by createdAt
|
||||
if (monitor.type === "infrastructure") {
|
||||
// For infrastructure and docker monitors we rely on $last in group, which requires ordering by createdAt
|
||||
if (monitor.type === "infrastructure" || monitor.type === "docker") {
|
||||
pipeline.push({ $sort: { createdAt: 1 } });
|
||||
}
|
||||
|
||||
@@ -666,6 +762,10 @@ class MonitorService implements IMonitorService {
|
||||
throw new ApiError("Monitor not found", 404);
|
||||
}
|
||||
|
||||
if (!monitor) {
|
||||
throw new ApiError("Monitor not found", 404);
|
||||
}
|
||||
|
||||
const startDate = getStartDate(range);
|
||||
|
||||
if (range === "24h" || range === "7d" || range === "30d") {
|
||||
|
||||
Reference in new issue
Block a user