diff --git a/client/src/hooks/forms/UseInitDockerForm.tsx b/client/src/hooks/forms/UseInitDockerForm.tsx new file mode 100644 index 000000000..872203468 --- /dev/null +++ b/client/src/hooks/forms/UseInitDockerForm.tsx @@ -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]); +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cf7e86cfb..8f08d9a76 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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": { diff --git a/client/src/pages/docker/DockerConfig.tsx b/client/src/pages/docker/DockerConfig.tsx new file mode 100644 index 000000000..366b57cd6 --- /dev/null +++ b/client/src/pages/docker/DockerConfig.tsx @@ -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>( + "/notification-channels" + ); + const { response: monitorResponse } = useGet>( + `/monitors/${id}` + ); + const monitor = monitorResponse?.data || null; + const notificationOptions = response?.data ?? []; + + const { patch, loading, error } = usePatch, 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 ( + + ); +}; + +export default DockerConfigurePage; diff --git a/client/src/pages/docker/DockerCreate.tsx b/client/src/pages/docker/DockerCreate.tsx new file mode 100644 index 000000000..1089892bb --- /dev/null +++ b/client/src/pages/docker/DockerCreate.tsx @@ -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>( + "/notification-channels" + ); + const { post, loading, error } = usePost(); + 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 ( + + ); +}; + +export default DockerCreatePage; + diff --git a/client/src/pages/docker/DockerDetails.tsx b/client/src/pages/docker/DockerDetails.tsx new file mode 100644 index 000000000..c6f14ae56 --- /dev/null +++ b/client/src/pages/docker/DockerDetails.tsx @@ -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 + >( + `/monitors/${id}?embedChecks=true&range=${range}`, + {}, + { refreshInterval: GLOBAL_REFRESH, keepPreviousData: true } + ); + + const { patch, loading: isPatching } = usePatch(); + + 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 ( + + + + + = 0 + ? `${prettyMilliseconds(lastChecked, { + secondsDecimalDigits: 0, + })} ago` + : "N/A" + } + /> + + + + + {JSON.stringify(latestCheck, null, 2)} + + + + ); +}; + +export default DockerDetailsPage; diff --git a/client/src/pages/docker/DockerForm.tsx b/client/src/pages/docker/DockerForm.tsx new file mode 100644 index 000000000..b8cd85d37 --- /dev/null +++ b/client/src/pages/docker/DockerForm.tsx @@ -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; +export type SubmitValues = Omit & { + interval: number | undefined; +}; + +export const DockerForm = ({ + mode = "create", + initialData, + onSubmit, + notificationOptions, + loading, +}: { + mode?: "create" | "configure"; + initialData?: IMonitor; + onSubmit: SubmitHandler; + notificationOptions: any[]; + loading: boolean; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const { defaults, formToApi } = useInitDockerForm({ initialData }); + + const { + handleSubmit, + control, + setValue, + reset, + formState: { errors }, + } = useForm({ + 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 ( + + + ( + + )} + /> + + ( + + )} + /> + ( + + )} + /> + + } + /> + ( + { + const target = e.target as HTMLInputElement; + field.onChange(target.valueAsNumber); + }} + /> + )} + /> + } + /> + + ( + option.name} + value={notificationOptions.filter((o: any) => + (field.value || []).includes(o._id) + )} + onChange={(_, newValue) => { + field.onChange(newValue.map((o: any) => o._id)); + }} + /> + )} + /> + + {notificationChannels.map((notificationId: string) => { + const option = notificationOptions.find( + (o: any) => o._id === notificationId + ); + if (!option) return null; + return ( + + {option.name} + { + const updated = notificationChannels.filter( + (id: string) => id !== notificationId + ); + setValue("notificationChannels", updated); + }} + style={{ cursor: "pointer" }} + /> + + ); + })} + + + } + /> + ( + + )} + /> + } + /> + + + + + ); +}; + +export default DockerForm; diff --git a/client/src/pages/docker/DockerMonitors.tsx b/client/src/pages/docker/DockerMonitors.tsx index b8156097e..3018449bb 100644 --- a/client/src/pages/docker/DockerMonitors.tsx +++ b/client/src/pages/docker/DockerMonitors.tsx @@ -107,6 +107,8 @@ const DockerMonitorsPage = () => { void; rowsPerPage: number; setRowsPerPage: (rowsPerPage: number) => void; + refetch: Function; + setSelectedMonitor: Function; }) => { const { t } = useTranslation(); - + const navigate = useNavigate(); + const theme = useTheme(); + const { patch } = usePatch(); + console.log(monitors); const handlePageChange = ( _e: React.MouseEvent | 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: ( + + {t("common.buttons.delete")} + + ), + action: () => setSelectedMonitor(monitor), + closeMenu: true, + }, + ]; + const headers: Header[] = [ { id: "name", content: ( - {t("common.table.headers.name")} + + {t("common.table.headers.name")} + ), render: (row) => row.name, }, @@ -50,11 +109,33 @@ export const DockerMonitorsTable = ({ ), render: (row) => , }, + { + id: "runningContainers", + content: ( + + {t("monitors.docker.table.headers.runningContainers")} + + ), + render: (row) => { + const containerCount = (row.latestChecks?.[0]?.dockerContainers || []) + .length; + return containerCount; + }, + }, + { + id: "actions", + content: {t("common.table.headers.actions")}, + render: (row) => , + }, ]; return ( <> - +
navigate(`/docker/${row._id}`)} + /> { /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/types/check.ts b/client/src/types/check.ts index ba0bc8ce9..b6d1e4b4f 100644 --- a/client/src/types/check.ts +++ b/client/src/types/check.ts @@ -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; +} diff --git a/client/src/validation/docker.ts b/client/src/validation/docker.ts new file mode 100644 index 000000000..78c98ccfc --- /dev/null +++ b/client/src/validation/docker.ts @@ -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), +}); diff --git a/client/src/validation/index.ts b/client/src/validation/index.ts index 49dde829e..de61ee8e5 100644 --- a/client/src/validation/index.ts +++ b/client/src/validation/index.ts @@ -13,3 +13,4 @@ export * from "./invite"; export * from "./recovery"; export * from "./reset"; export * from "./profile"; +export * from "./docker"; diff --git a/server/src/services/business/MonitorService.ts b/server/src/services/business/MonitorService.ts index eaad9003a..eb8a42720 100644 --- a/server/src/services/business/MonitorService.ts +++ b/server/src/services/business/MonitorService.ts @@ -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 => { + 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") {