details barebones

This commit is contained in:
Alex Holliday committed 2025-12-29 23:00:39 +00:00
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]);
};
+5
View File
@@ -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": {
+49
View File
@@ -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;
+34
View File
@@ -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;
+88
View File
@@ -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;
+240
View File
@@ -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}
+6
View File
@@ -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 />} />
+37
View File
@@ -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;
}
+16
View File
@@ -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),
});
+1
View File
@@ -13,3 +13,4 @@ export * from "./invite";
export * from "./recovery";
export * from "./reset";
export * from "./profile";
export * from "./docker";
+104 -4
View File
@@ -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") {