diff --git a/client/package.json b/client/package.json index a26499805..16f06aced 100644 --- a/client/package.json +++ b/client/package.json @@ -12,7 +12,8 @@ "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "format": "prettier --write .", - "format-check": "prettier --check ." + "format-check": "prettier --check .", + "compile": "tsc -b" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" diff --git a/client/src/Components/design-elements/BaseBox.tsx b/client/src/Components/design-elements/BaseBox.tsx index 580d15dcf..d5af820e6 100644 --- a/client/src/Components/design-elements/BaseBox.tsx +++ b/client/src/Components/design-elements/BaseBox.tsx @@ -1,10 +1,12 @@ import Box, { type BoxProps } from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; +import { forwardRef } from "react"; -export const BaseBox = ({ sx, ...rest }: BoxProps) => { +export const BaseBox = forwardRef(({ sx, ...rest }, ref) => { const theme = useTheme(); return ( { }} /> ); -}; +}); + +BaseBox.displayName = "BaseBox"; diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index 543a9f165..343e0b558 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -6,7 +6,7 @@ import { useState } from "react"; import { useToast } from "@/Hooks/UseToast"; import { logger } from "@/Utils/logger"; -interface ApiResponse { +export interface ApiResponse { success: boolean; msg: string; data: T; diff --git a/client/src/Hooks/UseToast.ts b/client/src/Hooks/UseToast.ts index a0da495bc..29f340238 100644 --- a/client/src/Hooks/UseToast.ts +++ b/client/src/Hooks/UseToast.ts @@ -1,27 +1,30 @@ import { toast, type ToastOptions } from "react-toastify"; +import { useMemo } from "react"; export const useToast = () => { - const showToast = (message: string, options?: ToastOptions) => { - toast.dismiss(); - const baseStyle: React.CSSProperties = { - whiteSpace: "nowrap", - overflow: "hidden", - textOverflow: "ellipsis", + return useMemo(() => { + const showToast = (message: string, options?: ToastOptions) => { + toast.dismiss(); + const baseStyle: React.CSSProperties = { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }; + toast(message, { + ...options, + style: { ...baseStyle, ...(options?.style || {}) }, + }); }; - toast(message, { - ...options, - style: { ...baseStyle, ...(options?.style || {}) }, - }); - }; - const toastSuccess = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "success" }); - const toastError = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "error" }); - const toastInfo = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "info" }); - const toastWarning = (msg: string, opts?: ToastOptions) => - showToast(msg, { ...opts, type: "warning" }); + const toastSuccess = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "success" }); + const toastError = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "error" }); + const toastInfo = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "info" }); + const toastWarning = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "warning" }); - return { showToast, toastSuccess, toastError, toastInfo, toastWarning }; + return { showToast, toastSuccess, toastError, toastInfo, toastWarning }; + }, []); }; diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 5458a75d8..043796ccb 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -77,6 +77,7 @@ export const getMonitorDefaults = ( ...base, type: "docker", url: data?.url || "", + dockerLogsEnabled: data?.dockerLogsEnabled ?? false, }; break; case "game": diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 5c2b693e0..3658276cd 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -118,8 +118,8 @@ const getGeneralSettingsConfig = ( showDnsRecordType: false, }, docker: { - urlLabel: t("pages.createMonitor.form.general.option.container.label"), - urlPlaceholder: t("pages.createMonitor.form.general.option.container.placeholder"), + urlLabel: t("pages.createMonitor.form.general.option.dockerHost.label"), + urlPlaceholder: t("pages.createMonitor.form.general.option.dockerHost.placeholder"), namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"), showUrl: true, showProxy: false, @@ -255,6 +255,7 @@ const CreateMonitorPage = () => { const firstSegment = pathSegments[0]; if (firstSegment === "pagespeed") return "pagespeed"; if (firstSegment === "infrastructure") return "hardware"; + if (firstSegment === "docker") return "docker"; return "uptime"; }, [location.pathname]); @@ -264,7 +265,9 @@ const CreateMonitorPage = () => { ? "pagespeed" : pageType === "hardware" ? "hardware" - : "http"; + : pageType === "docker" + ? "docker" + : "http"; const { data: existingMonitor, refetch: refetchMonitor } = useGet( isEditMode ? `/monitors/${monitorId}` : null @@ -373,6 +376,8 @@ const CreateMonitorPage = () => { navigate("/pagespeed"); } else if (pageType === "hardware") { navigate("/infrastructure"); + } else if (pageType === "docker") { + navigate("/docker"); } else { navigate("/uptime"); } @@ -823,6 +828,19 @@ const CreateMonitorPage = () => { /> )} + {showStep(1) && watchedType === "docker" && ( + + } + /> + )} + {showStep(2) && (watchedType === "http" || watchedType === "grpc" || diff --git a/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx new file mode 100644 index 000000000..b1ccddc2c --- /dev/null +++ b/client/src/Pages/Docker/ContainerDetails/Components/HeaderLogs.tsx @@ -0,0 +1,69 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { Dot } from "@/Components/design-elements"; + +// Hooks +import { useEffect, useState } from "react"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +// Utils +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { MS_PER_SECOND, MS_PER_MINUTE } from "@/Utils/TimeUtils"; + +interface HeaderLogsProps { + lines: number; + loading: boolean; + lastUpdatedAt: number | null; +} + +export const HeaderLogs = ({ lines, loading, lastUpdatedAt }: HeaderLogsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (lastUpdatedAt === null) return; + setNow(Date.now()); + const id = window.setInterval(() => setNow(Date.now()), MS_PER_SECOND); + return () => window.clearInterval(id); + }, [lastUpdatedAt]); + + const renderStatus = () => { + if (loading) return t("pages.docker.container.logs.loading"); + if (lastUpdatedAt === null) return null; + const ageMs = Math.max(0, now - lastUpdatedAt); + return ageMs < MS_PER_MINUTE + ? t("pages.docker.container.logs.updatedSecondsAgo", { + count: Math.floor(ageMs / MS_PER_SECOND), + }) + : t("pages.docker.container.logs.updatedMinutesAgo", { + count: Math.floor(ageMs / MS_PER_MINUTE), + }); + }; + + return ( + + + {t("pages.docker.container.logs.recentLines", { count: lines })} + + + {lastUpdatedAt !== null && !loading && ( + + )} + {renderStatus()} + + + ); +}; diff --git a/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx new file mode 100644 index 000000000..4a022e44e --- /dev/null +++ b/client/src/Pages/Docker/ContainerDetails/Components/RowLog.tsx @@ -0,0 +1,78 @@ +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import Divider from "@mui/material/Divider"; +import { Fragment } from "react"; + +// Hooks +import { useTheme } from "@mui/material"; +import { useSelector } from "react-redux"; +import { useTranslation } from "react-i18next"; + +// Utils +import type { DockerLogRow } from "@/Utils/MonitorUtils"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { RootState } from "@/store"; + +export const RowLog = ({ row }: { row: DockerLogRow }) => { + const theme = useTheme(); + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const { t } = useTranslation(); + + return ( + + + + + {formatDateWithTz(row.ts, "MMM D HH:mm:ss", uiTimezone)} + + + + + + {row.stream} + + : {row.text} + + + + {row.gapBefore ? ( + + + {t("pages.docker.container.logs.skipped")} + + + + ) : ( + + )} + + ); +}; diff --git a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx index 9dcea4842..e7e29207d 100644 --- a/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx +++ b/client/src/Pages/Docker/ContainerDetails/TabLogs.tsx @@ -1,3 +1,241 @@ -export const TabLogs = () => { - return "This is the logs tab"; +// Components +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { BaseBox, EmptyState } from "@/Components/design-elements"; +import { Button } from "@/Components/inputs"; +import { HeaderLogs } from "@/Pages/Docker/ContainerDetails/Components/HeaderLogs"; +import { RowLog } from "@/Pages/Docker/ContainerDetails/Components/RowLog"; + +// Types +import type { DockerContainerLogsResponse } from "@/Types/Monitor"; +import type { DockerLog } from "@/Types/Check"; +import type { ApiResponse } from "@/Hooks/UseApi"; + +// Hooks +import { + useState, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useCallback, +} from "react"; +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/Hooks/UseToast"; + +// Utils +import { get } from "@/Utils/ApiClient"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { flattenDockerLogs } from "@/Utils/MonitorUtils"; + +interface TabLogsProps { + monitorId?: string; + containerName?: string; + enabled: boolean; +} + +const SCROLL_PIN_THRESHOLD_PX = 8; +const TAIL_POLL_MS = 10_000; + +const isScrolledToBottom = (el: HTMLElement) => + el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_PIN_THRESHOLD_PX; + +const fetchLogs = async (url: string, params: { before?: string; after?: string }) => { + const res = await get>(url, { params }); + return res.data.data; +}; + +export const TabLogs = ({ monitorId, containerName, enabled }: TabLogsProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { toastError } = useToast(); + + // logs is newest-first, matching the API and what flattenDockerLogs expects. + const [logs, setLogs] = useState([]); + const [olderExhausted, setOlderExhausted] = useState(false); + const [loadingInitial, setLoadingInitial] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + const logsUrl = + enabled && monitorId && containerName + ? `/monitors/docker/details/${encodeURIComponent(monitorId)}/containers/${encodeURIComponent(containerName)}/logs` + : null; + + const newestCursor = logs[0]?.checkedAt; + const oldestCursor = logs[logs.length - 1]?.checkedAt; + + // Initial page. Resets everything when the container changes. + useEffect(() => { + setLogs([]); + setOlderExhausted(false); + if (!logsUrl) return; + let cancelled = false; + setLoadingInitial(true); + fetchLogs(logsUrl, {}) + .then((page) => { + if (cancelled) return; + setLogs(page.logs); + setOlderExhausted(page.nextCursor === null); + setLastUpdatedAt(Date.now()); + }) + .catch(() => { + if (cancelled) return; + toastError(t("pages.docker.container.logs.loadError")); + }) + .finally(() => { + if (!cancelled) setLoadingInitial(false); + }); + return () => { + cancelled = true; + }; + }, [logsUrl, t, toastError]); + + const newestCursorRef = useRef(newestCursor); + newestCursorRef.current = newestCursor; + + useEffect(() => { + if (!logsUrl) return; + let inFlight = false; + const tick = async () => { + if (inFlight || document.hidden) return; + inFlight = true; + try { + const cursor = newestCursorRef.current; + const page = await fetchLogs(logsUrl, cursor ? { after: cursor } : {}); + if (page.logs.length > 0) setLogs((prev) => [...page.logs, ...prev]); + setLastUpdatedAt(Date.now()); + } catch (err: unknown) { + console.error(err); + } finally { + inFlight = false; + } + }; + const id = window.setInterval(tick, TAIL_POLL_MS); + document.addEventListener("visibilitychange", tick); + return () => { + window.clearInterval(id); + document.removeEventListener("visibilitychange", tick); + }; + }, [logsUrl]); + + const rows = useMemo(() => flattenDockerLogs(logs), [logs]); + + const logBoxRef = useRef(null); + const pinnedToBottom = useRef(true); + const prependAnchor = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); + + // Always pin to bottom when container/monitor changes + useLayoutEffect(() => { + pinnedToBottom.current = true; + }, [monitorId, containerName]); + + // Rows changed: restore position after a prepend, otherwise honour the pin + useLayoutEffect(() => { + const el = logBoxRef.current; + if (!el) return; + const anchor = prependAnchor.current; + if (anchor) { + prependAnchor.current = null; + el.scrollTop = el.scrollHeight - anchor.scrollHeight + anchor.scrollTop; + return; + } + if (pinnedToBottom.current) el.scrollTop = el.scrollHeight; + }, [rows]); + + const handleScroll = useCallback((event: React.UIEvent) => { + pinnedToBottom.current = isScrolledToBottom(event.currentTarget); + }, []); + + const loadOlder = useCallback(async () => { + if (!logsUrl || !oldestCursor || loadingOlder) return; + setLoadingOlder(true); + try { + const page = await fetchLogs(logsUrl, { before: oldestCursor }); + const el = logBoxRef.current; + if (el) + prependAnchor.current = { + scrollHeight: el.scrollHeight, + scrollTop: el.scrollTop, + }; + setLogs((prev) => [...prev, ...page.logs]); + setOlderExhausted(page.nextCursor === null); + } catch { + toastError(t("pages.docker.container.logs.loadOlderError")); + } finally { + setLoadingOlder(false); + } + }, [logsUrl, oldestCursor, loadingOlder, t, toastError]); + + if (!enabled) { + return ( + + ); + } + + if (lastUpdatedAt !== null && rows.length === 0) { + return ( + + ); + } + + return ( + + + + {rows.length > 0 && ( + + {olderExhausted ? ( + + {t("pages.docker.container.logs.noOlder")} + + ) : ( + + )} + + )} + {rows.map((row) => { + return ( + + ); + })} + + + ); }; diff --git a/client/src/Pages/Docker/ContainerDetails/index.tsx b/client/src/Pages/Docker/ContainerDetails/index.tsx index b4583e4c1..6bd30c733 100644 --- a/client/src/Pages/Docker/ContainerDetails/index.tsx +++ b/client/src/Pages/Docker/ContainerDetails/index.tsx @@ -21,7 +21,7 @@ const TAB_MAP = { logs: 1, } as const; -const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) => { +const DockerDetailsPage = ({ open = "logs" }: DockerContainerDetailsProps) => { const [activeTab, setActiveTab] = useState(TAB_MAP[open]); const [dateRange, setDateRange] = useState("recent"); const { monitorId, containerName } = useParams<{ @@ -70,7 +70,13 @@ const DockerDetailsPage = ({ open = "overview" }: DockerContainerDetailsProps) = {activeTab === 0 && } - {activeTab === 1 && } + {activeTab === 1 && ( + + )} ); }; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index 419ff7736..1585b7771 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -161,6 +161,31 @@ export interface DockerContainerMount { rw: boolean; } +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; + +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerLog { + id: string; + metadata: { + monitorId: string; + teamId: string; + containerId: string; + containerName: string; + }; + lines: DockerLogLine[]; + gap: boolean; + checkedAt: string; + expiry: string; + createdAt: string; + updatedAt: string; +} + export interface DockerContainerInfo { id: string; name: string; diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index a0ed555d4..c81df9c82 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -5,6 +5,7 @@ import type { DailyCheckBucket, DockerContainerInfo, DockerContainerSummary, + DockerLog, } from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; @@ -158,6 +159,7 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dockerLogsEnabled?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; @@ -322,6 +324,10 @@ export interface DockerContainerResponse { stats: DockerContainerStats; } +export interface DockerContainerLogsResponse { + logs: DockerLog[]; + nextCursor: string | null; +} export interface Game { name: string; options?: { diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 3a7778b75..cdc85ac84 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -1,10 +1,13 @@ import type { MonitorStatus, MonitorType } from "@/Types/Monitor"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import type { ValueType } from "@/Components/design-elements/StatusLabel"; -import type { - DockerContainerMount, - DockerContainerPort, - DockerContainerState, +import { + DockerLogStreams, + type DockerContainerMount, + type DockerContainerPort, + type DockerContainerState, + type DockerLog, + type DockerLogLine, } from "@/Types/Check"; export const getMonitorPath = (type: MonitorType): string => { @@ -144,3 +147,35 @@ export const getDockerMountLabel = (mount: DockerContainerMount) => { if (mount.source) return mount.source; return mount.type; }; + +export const DockerLogStreamFilters = ["all", ...DockerLogStreams] as const; +export type DockerLogStreamFilter = (typeof DockerLogStreamFilters)[number]; + +export interface DockerLogRow extends DockerLogLine { + key: string; + gapBefore: boolean; // This signifies lines skipped +} + +export const flattenDockerLogs = (logs: DockerLog[]): DockerLogRow[] => { + const rows: DockerLogRow[] = []; + for (const log of [...logs].reverse()) { + log.lines.forEach((line: DockerLogLine, idx: number) => { + rows.push({ ...line, key: `${log.id}:${idx}`, gapBefore: idx === 0 && log.gap }); + }); + } + return rows; +}; + +export const filterDockerLogRows = ( + rows: DockerLogRow[], + stream: DockerLogStreamFilter, + query: string +): DockerLogRow[] => { + const normalizedQuery = query.trim().toLowerCase(); + return rows.filter((row) => { + return ( + (stream === "all" || row.stream === stream) && + (normalizedQuery === "" || row.text.toLowerCase().includes(normalizedQuery)) + ); + }); +}; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 00ab3aaff..3a1f479dd 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -111,7 +111,8 @@ const portSchema = baseSchema.extend({ // Docker monitor schema const dockerSchema = baseSchema.extend({ type: z.literal("docker"), - url: z.string().min(1, "Container ID is required"), + url: z.string().min(1, "Docker host URL is required"), + dockerLogsEnabled: z.boolean().register(monitorStepRegistry, { step: 1 }), }); // Game server monitor schema diff --git a/client/src/locales/en.json b/client/src/locales/en.json index cbebadded..9582a44dd 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -632,9 +632,9 @@ }, "general": { "option": { - "container": { - "label": "Container name/ID", - "placeholder": "my-app or abcd1234" + "dockerHost": { + "label": "Docker host", + "placeholder": "unix:///var/run/docker.sock" }, "host": { "label": "Host", @@ -716,7 +716,7 @@ "http": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard. Set an optional proxy server to connect through.", "ping": "Enter the IP address or hostname to ping (e.g., 192.168.1.100 or example.com) and add a clear display name that appears on the dashboard.", "port": "Enter the URL or IP of the server, the port number and a clear display name that appears on the dashboard.", - "docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).", + "docker": "Enter the Docker socket to monitor, as unix:///var/run/docker.sock or an absolute path such as /var/run/docker.sock. Every container on the host is monitored.", "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type. Some games require enabling server queries in their config file — check your game server's documentation. See the supported games list for protocol details.", "pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.", "grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.", @@ -756,7 +756,7 @@ }, "type": { "description": "Select the type of check to perform", - "optionDockerDescription": "Use Docker to monitor if a container is running.", + "optionDockerDescription": "Monitor a Docker host and every container running on it.", "optionGameDescription": "Monitor if a specific game server is online.", "optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.", "optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.", @@ -814,6 +814,15 @@ } } }, + "dockerLogs": { + "title": "Container logs", + "description": "Collect the most recent log lines from every container on this host on each check. Logs are kept for 7 days. Leave this off for hosts with chatty containers to save database space.", + "option": { + "enabled": { + "label": "Collect container logs" + } + } + }, "stats": { "title": "Monitor history", "description": "Clear all monitoring history and statistics for your team. This action is irreversible.", @@ -956,6 +965,27 @@ "tabs": { "overview": "Overview", "logs": "Logs" + }, + "logs": { + "recentLines": "{{count}} recent lines", + "loading": "Loading...", + "updatedSecondsAgo": "Updated {{count}}s ago", + "updatedMinutesAgo": "Updated {{count}}m ago", + "skipped": "Some log entries were skipped", + "loadOlder": "Load older logs", + "loadingOlder": "Loading...", + "noOlder": "Beginning of retained logs", + "loadError": "Could not load logs", + "loadOlderError": "Could not load older logs", + "empty": { + "title": "No logs collected yet", + "description": "Lines will appear here after the next check. Only output written since collection was turned on is stored." + }, + "disabled": { + "title": "Log collection is off for this host", + "description": "Turn on container logs in the monitor settings to start collecting.", + "action": "Configure monitor" + } } }, "host": { diff --git a/server/openapi/routes/monitor.ts b/server/openapi/routes/monitor.ts index f7d92c94e..533f9f9c3 100644 --- a/server/openapi/routes/monitor.ts +++ b/server/openapi/routes/monitor.ts @@ -24,6 +24,8 @@ import { dockerContainerDetailsResponseSchema, monitorResponseSchema, uptimeDetailsResponseSchema, + getDockerContainerLogsQueryValidation, + dockerContainerLogsResponseSchema, } from "@/api/validation/monitorValidation.js"; import { updateNotificationsValidation } from "@/api/validation/notificationValidation.js"; @@ -131,6 +133,16 @@ registry.registerPath({ responses: { "200": okJson(dockerContainerDetailsResponseSchema), ...standardErrors }, }); +registry.registerPath({ + method: "get", + path: "/monitors/docker/details/{monitorId}/containers/{containerName}/logs", + tags, + summary: "Get stored log lines for one container on a Docker host monitor, newest first", + security: bearer, + request: { params: getDockerContainerNameParamValidation, query: getDockerContainerLogsQueryValidation }, + responses: { "200": okJson(dockerContainerLogsResponseSchema), ...standardErrors }, +}); + registry.registerPath({ method: "get", path: "/monitors/pagespeed/details/{monitorId}", diff --git a/server/src/api/controllers/monitorController.ts b/server/src/api/controllers/monitorController.ts index af73d786a..12e8f8520 100644 --- a/server/src/api/controllers/monitorController.ts +++ b/server/src/api/controllers/monitorController.ts @@ -21,6 +21,7 @@ import { getDockerDetailsByIdQueryValidation, getDockerContainerNameParamValidation, getDockerContainerByNameQueryValidation, + getDockerContainerLogsQueryValidation, } from "@/api/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import * as whoiser from "whoiser"; @@ -37,6 +38,7 @@ export interface IMonitorController { getPageSpeedDetailsById: RequestHandler; getDockerDetailsById: RequestHandler; getDockerContainerByName: RequestHandler; + getDockerContainerLogs: RequestHandler; getGeoChecksByMonitorId: RequestHandler; getMonitorById: RequestHandler; createMonitor: RequestHandler; @@ -198,6 +200,30 @@ class MonitorController implements IMonitorController { }); }); + getDockerContainerLogs = catchAsync(async (req: Request, res: Response) => { + const validatedParams = getDockerContainerNameParamValidation.parse(req.params); + const validatedQuery = getDockerContainerLogsQueryValidation.parse(req.query); + + const { monitorId, containerName } = validatedParams; + const { before, after, limit } = validatedQuery; + const teamId = requireTeamId(req.user?.teamId); + + const data = await this.monitorService.getDockerContainerLogs({ + teamId, + monitorId, + containerName, + before: before ? new Date(before) : undefined, + after: after ? new Date(after) : undefined, + limit, + }); + + return res.status(200).json({ + success: true, + msg: "Docker container logs retrieved successfully", + data, + }); + }); + getGeoChecksByMonitorId = catchAsync(async (req: Request, res: Response) => { const validatedParams = getMonitorByIdParamValidation.parse(req.params); const validatedQuery = getMonitorByIdQueryValidation.parse(req.query); diff --git a/server/src/api/routes/monitorRoutes.ts b/server/src/api/routes/monitorRoutes.ts index 5192f6619..dab4614cd 100644 --- a/server/src/api/routes/monitorRoutes.ts +++ b/server/src/api/routes/monitorRoutes.ts @@ -21,6 +21,7 @@ export const createMonitorRoutes = (monitorController: IMonitorController): Rout // Docker routes router.get("/docker/details/:monitorId", monitorController.getDockerDetailsById); router.get("/docker/details/:monitorId/containers/:containerName", monitorController.getDockerContainerByName); + router.get("/docker/details/:monitorId/containers/:containerName/logs", monitorController.getDockerContainerLogs); // Geo checks routes router.get("/:monitorId/geo-checks", monitorController.getGeoChecksByMonitorId); diff --git a/server/src/api/validation/monitorValidation.ts b/server/src/api/validation/monitorValidation.ts index 2d3a16c49..ddea832ef 100644 --- a/server/src/api/validation/monitorValidation.ts +++ b/server/src/api/validation/monitorValidation.ts @@ -13,7 +13,8 @@ import { ProxyModes, } from "@/domain/monitors/monitor.type.js"; import { DateRanges, SortOrders } from "@/types/query.js"; -import { DockerContainerStates, DockerHealthStatuses, DockerPortProtocols } from "@/types/network.js"; +import { DockerContainerStates, DockerHealthStatuses, DockerLogStreams, DockerPortProtocols } from "@/domain/docker/docker.type.js"; +import { DOCKER_LOG_PAGE_DEFAULT, DOCKER_LOG_PAGE_MAX } from "@/domain/docker/docker-log.type.js"; const httpStatusCode = z.number().refine((code) => HttpStatusCodeSet.has(code), { message: "Must be a valid HTTP status code" }); @@ -119,22 +120,15 @@ const refineProxySelection = (body: { proxyMode?: string; proxyId?: string }, ct } }; -const dockerUrlRegex = /^(?:ssh:\/\/[^@\s]+@[^\s/:@]+(?::\d{1,5})?\/?|unix:\/\/\/\S+|\/\S+)$/; +const dockerUrlRegex = /^(?:unix:\/\/\/\S+|\/\S+)$/; -const refineDockerUrl = (data: { type?: string; url?: string; sshPrivateKey?: string }, ctx: z.RefinementCtx) => { +const refineDockerUrl = (data: { type?: string; url?: string }, ctx: z.RefinementCtx) => { if (data.type !== "docker" || data.url === undefined) return; if (!dockerUrlRegex.test(data.url)) { ctx.addIssue({ code: "custom", path: ["url"], - message: "Docker host must be ssh://user@host[:port], unix:///path, or an absolute socket path", - }); - } - if (data.url.startsWith("ssh://") && !data.sshPrivateKey) { - ctx.addIssue({ - code: "custom", - path: ["sshPrivateKey"], - message: "SSH Docker hosts require a private key", + message: "Docker host must be unix:///path or an absolute socket path", }); } }; @@ -163,7 +157,6 @@ export const createMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), jsonPath: z.union([z.string(), z.literal("")]).optional(), expectedValue: z.union([z.string(), z.literal("")]).optional(), matchMethod: z.union([z.enum(MonitorMatchMethods), z.literal("")]).optional(), @@ -176,6 +169,7 @@ export const createMonitorBodyValidation = z geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dockerLogsEnabled: z.boolean().optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -199,7 +193,6 @@ export const editMonitorBodyValidation = z tags: z.array(z.string()).optional(), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), ignoreTlsErrors: z.boolean().optional(), proxyMode: z.enum(ProxyModes).optional(), proxyId: proxyIdValidation, @@ -221,6 +214,7 @@ export const editMonitorBodyValidation = z geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + dockerLogsEnabled: z.boolean().optional(), dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), }) @@ -280,7 +274,6 @@ const importedMonitorSchema = z tags: z.array(z.string()).default([]), customUpCodes: z.array(httpStatusCode).default([]), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), cpuAlertThreshold: z.number().default(100), cpuAlertCounter: z.number().default(5), memoryAlertThreshold: z.number().default(100), @@ -297,6 +290,8 @@ const importedMonitorSchema = z geoCheckEnabled: z.boolean().default(false), geoCheckLocations: z.array(z.enum(GeoContinents)).default([]), geoCheckInterval: z.number().min(300000).default(300000), + dockerLogsEnabled: z.boolean().default(false), + dnsServer: dnsServerValidation.optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), createdAt: z.string().optional(), @@ -332,6 +327,21 @@ export const getDockerContainerNameParamValidation = z.object({ }); export const getDockerContainerByNameQueryValidation = z.object({ dateRange: z.enum(DateRanges).optional() }); +export const getDockerContainerLogsQueryValidation = z + .object({ + before: z.iso.datetime().optional(), + after: z.iso.datetime().optional(), + limit: z.coerce.number().int().min(1).max(DOCKER_LOG_PAGE_MAX).default(DOCKER_LOG_PAGE_DEFAULT), + }) + .superRefine((query, ctx) => { + if (query.before && query.after) { + ctx.addIssue({ + code: "custom", + message: "Specify either before or after, not both", + path: ["after"], + }); + } + }); // Canonical monitor shape returned by /monitors endpoints. Keep aligned with // what the controllers actually serialize. export const monitorResponseSchema = z @@ -359,7 +369,6 @@ export const monitorResponseSchema = z tags: z.array(z.string()), customUpCodes: z.array(httpStatusCode).optional(), secret: z.string().optional(), - sshPrivateKey: z.string().optional(), cpuAlertThreshold: z.number(), memoryAlertThreshold: z.number(), diskAlertThreshold: z.number(), @@ -371,6 +380,7 @@ export const monitorResponseSchema = z geoCheckEnabled: z.boolean(), geoCheckLocations: z.array(z.enum(GeoContinents)), geoCheckInterval: z.number(), + dockerLogsEnabled: z.boolean(), dnsServer: z.string().optional(), dnsRecordType: z.enum(DnsRecordTypes).optional(), teamId: z.string(), @@ -529,3 +539,26 @@ export const dockerContainerDetailsResponseSchema = z.object({ .nullable(), }), }); + +export const dockerLogLineResponseSchema = z.object({ + ts: z.string(), + stream: z.enum(DockerLogStreams), + text: z.string(), +}); + +export const dockerLogResponseSchema = z.object({ + id: z.string(), + metadata: z.object({ monitorId: z.string(), teamId: z.string(), containerId: z.string(), containerName: z.string() }), + lines: z.array(dockerLogLineResponseSchema), + gap: z.boolean(), + checkedAt: z.string(), + expiry: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}); + +// Response of GET /monitors/docker/details/{monitorId}/containers/{containerName}/logs. +export const dockerContainerLogsResponseSchema = z.object({ + logs: z.array(dockerLogResponseSchema), + nextCursor: z.string().nullable(), +}); diff --git a/server/src/config/services.api.ts b/server/src/config/services.api.ts index 5ce76aa3d..65607107c 100644 --- a/server/src/config/services.api.ts +++ b/server/src/config/services.api.ts @@ -35,6 +35,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, usersRepository, @@ -75,6 +76,7 @@ export const buildApi = (shared: SharedServices, jobScheduler: IJobScheduler): A monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, incidentsRepository, diff --git a/server/src/config/services.shared.ts b/server/src/config/services.shared.ts index 7ea873c4b..76edfbc99 100644 --- a/server/src/config/services.shared.ts +++ b/server/src/config/services.shared.ts @@ -13,6 +13,7 @@ import { IDb } from "@/db/db.interface.js"; import { ISettingsService, EnvConfig, SettingsService } from "@/domain/app-settings/app-settings.service.js"; import { ICheckService, CheckService } from "@/domain/checks/check.service.js"; import { IGeoChecksService, GeoChecksService } from "@/domain/geo-checks/geo-check.service.js"; +import { IDockerLogsService, DockerLogsService } from "@/domain/docker/docker-log.service.js"; import { IIncidentService, IncidentService } from "@/domain/incidents/incident.service.js"; import { INotificationMessageBuilder, NotificationMessageBuilder } from "@/domain/notifications/notification.message-builder.js"; import { INotificationsService, NotificationsService } from "@/domain/notifications/notification.service.js"; @@ -58,6 +59,7 @@ import { IProxiesRepository } from "@/domain/proxies/proxy.repository.interface. import MongoSettingsRepository from "@/domain/app-settings/app-settings.repository.mongo.js"; import MongoChecksRepository from "@/domain/checks/check.repository.mongo.js"; import MongoGeoChecksRepository from "@/domain/geo-checks/geo-check.repository.mongo.js"; +import MongoDockerLogsRepository from "@/domain/docker/docker-log.repository.mongo.js"; import MongoIncidentsRepository from "@/domain/incidents/incident.repository.mongo.js"; import MongoInvitesRepository from "@/domain/invites/invite.repository.mongo.js"; import MongoJobsRepository from "@/domain/jobs/job.repository.mongo.js"; @@ -72,6 +74,7 @@ import MongoTagsRepository from "@/domain/tags/tag.repository.mongo.js"; import MongoTeamsRepository from "@/domain/teams/team.repository.model.js"; import MongoUsersRepository from "@/domain/users/user.repository.mongo.js"; import MongoProxiesRepository from "@/domain/proxies/proxy.repository.mongo.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; // Shared infrastructure + business services that both the API and the worker process construct. export interface SharedServices { @@ -83,6 +86,7 @@ export interface SharedServices { incidentService: IIncidentService; checkService: ICheckService; geoChecksService: IGeoChecksService; + dockerLogsService: IDockerLogsService; notificationsService: INotificationsService; // Queue identity (one per process; the worker reuses these) @@ -94,6 +98,7 @@ export interface SharedServices { monitorsRepository: IMonitorsRepository; checksRepository: IChecksRepository; geoChecksRepository: IGeoChecksRepository; + dockerLogsRepository: IDockerLogsRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; usersRepository: IUsersRepository; @@ -131,6 +136,7 @@ export const buildShared = async ({ const incidentsRepository = new MongoIncidentsRepository(); const usersRepository = new MongoUsersRepository(); const geoChecksRepository = new MongoGeoChecksRepository(); + const dockerLogsRepository = new MongoDockerLogsRepository(); const notificationsRepository = new MongoNotificationsRepository(); const jobsRepository = new MongoJobsRepository(workerId); const queueWorkersRepository = new MongoQueueWorkersRepository(); @@ -161,6 +167,7 @@ export const buildShared = async ({ monitorsRepository, }); + const dockerLogsService = new DockerLogsService({ logger, dockerLogsRepository }); const webhookProvider = new WebhookProvider(logger); const rocketChatProvider = new RocketChatProvider(logger); const slackProvider = new SlackProvider(logger); @@ -207,6 +214,7 @@ export const buildShared = async ({ incidentService, checkService, geoChecksService, + dockerLogsService, notificationsService, workerId, jobsRepository, @@ -214,6 +222,7 @@ export const buildShared = async ({ queueWorkersRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, usersRepository, diff --git a/server/src/config/services.worker.ts b/server/src/config/services.worker.ts index 9b4ea8609..edea2b045 100644 --- a/server/src/config/services.worker.ts +++ b/server/src/config/services.worker.ts @@ -52,6 +52,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig settingsService, checkService, geoChecksService, + dockerLogsService, notificationsService, incidentService, workerId, @@ -60,6 +61,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, incidentsRepository, teamsRepository, @@ -95,7 +97,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig ]); const proxyResolver = new ProxyResolver(proxiesRepository, settingsService, logger); - const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService, jobsRepository); + const bufferService = new BufferService(logger, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository); const statusService = new StatusService(logger, monitorsRepository, monitorStatsRepository); const monitorStatusPolicy = new MonitorStatusPolicy(); @@ -119,6 +121,7 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig networkService, proxyResolver, bufferService, + dockerLogsService, logger ); const checkEvaluator = new CheckEvaluator(statusService, monitorStatusPolicy); @@ -138,7 +141,8 @@ export const buildWorker = async (shared: SharedServices, envSettings: EnvConfig monitorStatsRepository, checksRepository, incidentsRepository, - geoChecksRepository + geoChecksRepository, + dockerLogsRepository ); const worker = await DBQueueWorker.create({ diff --git a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts index 75f3d5acf..d4d3bd143 100644 --- a/server/src/db/migration/0012_migrateDockerMonitorUrls.ts +++ b/server/src/db/migration/0012_migrateDockerMonitorUrls.ts @@ -7,8 +7,8 @@ import type { ILogger } from "@/utils/logger.js"; * The old docker provider interpreted monitor.url as a container name/ID and only * ever talked to the local socket, so every pre-existing docker monitor's host is * definitionally unix:///var/run/docker.sock. The container name is preserved in - * the description. Urls already in host form (unix://, ssh://, or an absolute - * socket path) are left untouched, which makes the migration idempotent. + * the description. Urls already in host form (unix:// or an absolute socket + * path) are left untouched, which makes the migration idempotent. */ export async function migrateDockerMonitorUrls(logger: ILogger): Promise { const SERVICE_NAME = "Migration:MigrateDockerMonitorUrls"; @@ -16,7 +16,7 @@ export async function migrateDockerMonitorUrls(logger: ILogger): Promise { try { logger.info({ service: SERVICE_NAME, message: "Starting docker monitor url migration" }); - const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^((unix|ssh):\/\/|\/)/ } }, [ + const result = await MonitorModel.updateMany({ type: "docker", url: { $not: /^(unix:\/\/|\/)/ } }, [ { $set: { description: { diff --git a/server/src/domain/checks/check.model.ts b/server/src/domain/checks/check.model.ts index cdab5fec7..a4319056e 100644 --- a/server/src/domain/checks/check.model.ts +++ b/server/src/domain/checks/check.model.ts @@ -22,7 +22,7 @@ import { DockerContainerSummary, DockerHealthStatuses, DockerPortProtocols, -} from "@/types/network.js"; +} from "@/domain/docker/docker.type.js"; type CheckMetadataDocument = Omit & { monitorId: Types.ObjectId; diff --git a/server/src/domain/checks/check.snapshot.ts b/server/src/domain/checks/check.snapshot.ts index 7348a2d55..65ef4f60a 100644 --- a/server/src/domain/checks/check.snapshot.ts +++ b/server/src/domain/checks/check.snapshot.ts @@ -11,7 +11,7 @@ import type { SnapshotHostInfo, SnapshotMemoryInfo, } from "@/domain/checks/check.type.js"; -import { DockerContainerSummary } from "@/types/network.js"; +import { DockerContainerSummary } from "@/domain/docker/docker.type.js"; import { toDateString } from "@/utils/mongoMappers.js"; export type CheckSnapshotSource = Pick< diff --git a/server/src/domain/checks/check.type.ts b/server/src/domain/checks/check.type.ts index feeb4439a..afea301be 100644 --- a/server/src/domain/checks/check.type.ts +++ b/server/src/domain/checks/check.type.ts @@ -1,5 +1,5 @@ import type { MonitorType } from "@/domain/monitors/monitor.type.js"; -import { DockerContainerInfo, DockerContainerSummary } from "@/types/network.js"; +import { DockerContainerInfo, DockerContainerSummary } from "@/domain/docker/docker.type.js"; import type { Response } from "got"; export const CHECK_TTL_SENTINEL = 366; diff --git a/server/src/domain/docker/docker-log.model.ts b/server/src/domain/docker/docker-log.model.ts new file mode 100644 index 000000000..269733ecd --- /dev/null +++ b/server/src/domain/docker/docker-log.model.ts @@ -0,0 +1,60 @@ +import { Schema, model, Types } from "mongoose"; +import { DockerLogStreams, type DockerLogLine } from "@/domain/docker/docker.type.js"; +import type { DockerLog, DockerLogMetadata } from "@/domain/docker/docker-log.type.js"; + +type DockerLogMetadataDocument = Omit & { + monitorId: Types.ObjectId; + teamId: Types.ObjectId; +}; + +type DockerLogDocumentBase = Omit & { + metadata: DockerLogMetadataDocument; + checkedAt: Date; + expiry: Date; + createdAt: Date; + updatedAt: Date; +}; + +export interface DockerLogDocument extends DockerLogDocumentBase { + _id: Types.ObjectId; +} + +const dockerLogMetadataSchema = new Schema( + { + monitorId: { type: Schema.Types.ObjectId, required: true }, + teamId: { type: Schema.Types.ObjectId, required: true }, + containerId: { type: String, required: true }, + containerName: { type: String, required: true }, + }, + { _id: false } +); + +const dockerLogLineSchema = new Schema( + { + ts: { type: String, required: true }, + stream: { type: String, enum: DockerLogStreams, required: true }, + text: { type: String, default: "" }, + }, + { _id: false } +); + +const DockerLogSchema = new Schema( + { + metadata: { type: dockerLogMetadataSchema, required: true }, + lines: { type: [dockerLogLineSchema], required: true, default: [] }, + gap: { type: Boolean, default: false }, + checkedAt: { type: Date, required: true }, + expiry: { type: Date, required: true, index: { expires: 0 } }, // TTL: Mongo deletes the doc once `expiry` passes + }, + { timestamps: true } +); + +DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerName": 1, checkedAt: -1 }); // API paging +DockerLogSchema.index({ "metadata.monitorId": 1, "metadata.containerId": 1, checkedAt: -1 }); // cursor seed +DockerLogSchema.index({ "metadata.teamId": 1 }); + +const DockerLogModel = model("DockerLog", DockerLogSchema); + +export type { DockerLogMetadataDocument }; +export { DockerLogModel }; +export default DockerLogModel; diff --git a/server/src/domain/docker/docker-log.repository.interface.ts b/server/src/domain/docker/docker-log.repository.interface.ts new file mode 100644 index 000000000..10f0ce8aa --- /dev/null +++ b/server/src/domain/docker/docker-log.repository.interface.ts @@ -0,0 +1,10 @@ +import { DockerLog } from "@/domain/docker/docker-log.type.js"; + +export interface IDockerLogsRepository { + createDockerLogs(logs: Omit[]): Promise; + findLastLineTimestamp(monitorId: string, containerId: string): Promise; + findByContainerName(args: { monitorId: string; containerName: string; before?: Date; after?: Date; limit: number }): Promise; + deleteByMonitorId(monitorId: string): Promise; + deleteByTeamId(teamId: string): Promise; + deleteByMonitorIdsNotIn(monitorIds: string[]): Promise; +} diff --git a/server/src/domain/docker/docker-log.repository.mongo.ts b/server/src/domain/docker/docker-log.repository.mongo.ts new file mode 100644 index 000000000..8ee90e535 --- /dev/null +++ b/server/src/domain/docker/docker-log.repository.mongo.ts @@ -0,0 +1,99 @@ +import DockerLogModel, { DockerLogDocument } from "@/domain/docker/docker-log.model.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; +import { DockerLog } from "@/domain/docker/docker-log.type.js"; +import { toDateString, toStringId } from "@/utils/mongoMappers.js"; +import mongoose from "mongoose"; + +const SERVICE_NAME = "DockerLogsRepository"; + +class MongoDockerLogsRepository implements IDockerLogsRepository { + static SERVICE_NAME = SERVICE_NAME; + + private toEntity = (doc: DockerLogDocument): DockerLog => { + return { + id: toStringId(doc._id), + metadata: { + monitorId: toStringId(doc.metadata.monitorId), + teamId: toStringId(doc.metadata.teamId), + containerId: doc.metadata.containerId, + containerName: doc.metadata.containerName, + }, + lines: (doc.lines ?? []).map((line) => ({ ts: line.ts, stream: line.stream, text: line.text ?? "" })), + gap: doc.gap ?? false, + checkedAt: toDateString(doc.checkedAt), + expiry: toDateString(doc.expiry), + createdAt: toDateString(doc.createdAt), + updatedAt: toDateString(doc.updatedAt), + }; + }; + + createDockerLogs = async (logs: Omit[]): Promise => { + if (logs.length === 0) return 0; + const docs = await DockerLogModel.insertMany( + logs.map((log) => { + return { + metadata: { + monitorId: new mongoose.Types.ObjectId(log.metadata.monitorId), + teamId: new mongoose.Types.ObjectId(log.metadata.teamId), + containerId: log.metadata.containerId, + containerName: log.metadata.containerName, + }, + lines: log.lines, + gap: log.gap, + checkedAt: new Date(log.checkedAt), + expiry: new Date(log.expiry), + }; + }), + { ordered: false } + ); + return docs.length; + }; + + findLastLineTimestamp = async (monitorId: string, containerId: string): Promise => { + const doc = await DockerLogModel.findOne({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.containerId": containerId, + }) + .sort({ checkedAt: -1 }) + .select({ lines: { $slice: -1 } }) + .lean>(); + return doc?.lines?.[0]?.ts ?? null; + }; + + findByContainerName = async ({ + monitorId, + containerName, + before, + after, + limit, + }: { + monitorId: string; + containerName: string; + before?: Date; + after?: Date; + limit: number; + }): Promise => { + const docs = await DockerLogModel.find({ + "metadata.monitorId": new mongoose.Types.ObjectId(monitorId), + "metadata.containerName": containerName, + ...(before ? { checkedAt: { $lt: before } } : {}), + ...(after ? { checkedAt: { $gt: after } } : {}), + }) + .sort({ checkedAt: after ? 1 : -1 }) + .limit(limit) + .lean(); + const entities = docs.map(this.toEntity); + return after ? entities.reverse() : entities; + }; + + deleteByMonitorId = async (monitorId: string) => + (await DockerLogModel.deleteMany({ "metadata.monitorId": new mongoose.Types.ObjectId(monitorId) })).deletedCount ?? 0; + + deleteByTeamId = async (teamId: string) => + (await DockerLogModel.deleteMany({ "metadata.teamId": new mongoose.Types.ObjectId(teamId) })).deletedCount ?? 0; + + deleteByMonitorIdsNotIn = async (monitorIds: string[]) => + (await DockerLogModel.deleteMany({ "metadata.monitorId": { $nin: monitorIds.map((id) => new mongoose.Types.ObjectId(id)) } })).deletedCount ?? 0; +} + +export default MongoDockerLogsRepository; diff --git a/server/src/domain/docker/docker-log.service.ts b/server/src/domain/docker/docker-log.service.ts new file mode 100644 index 000000000..646666bab --- /dev/null +++ b/server/src/domain/docker/docker-log.service.ts @@ -0,0 +1,100 @@ +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; +import { DOCKER_LOG_RETENTION_DAYS, DockerLog } from "@/domain/docker/docker-log.type.js"; +import { DOCKER_LOG_TAIL_LINES, DockerLogLine } from "@/domain/docker/docker.type.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; +import { ILogger } from "@/utils/logger.js"; +import mongoose from "mongoose"; + +const SERVICE_NAME = "DockerLogsService"; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface IDockerLogsService { + buildDockerLogs(status: MonitorStatusResponse): Promise; + createDockerLogs(logs: DockerLog[]): Promise; +} + +export class DockerLogsService implements IDockerLogsService { + static SERVICE_NAME = SERVICE_NAME; + + private logger: ILogger; + private dockerLogsRepository: IDockerLogsRepository; + + private lastTimestampByContainer = new Map(); + + constructor({ logger, dockerLogsRepository }: { logger: ILogger; dockerLogsRepository: IDockerLogsRepository }) { + this.logger = logger; + this.dockerLogsRepository = dockerLogsRepository; + } + + private generateCursorKey = (monitorId: string, containerId: string) => `${monitorId}:${containerId}`; + + private getLastTimestamp = async (monitorId: string, containerId: string) => { + const key = this.generateCursorKey(monitorId, containerId); + const cached = this.lastTimestampByContainer.get(key); + if (cached !== undefined) return cached || null; + const stored = await this.dockerLogsRepository.findLastLineTimestamp(monitorId, containerId); + this.lastTimestampByContainer.set(key, stored ?? ""); + return stored; + }; + + private newLines = (lines: DockerLogLine[], lastTimestamp: string | null): DockerLogLine[] => { + return lastTimestamp + ? lines.filter((line) => { + return line.ts > lastTimestamp; + }) + : lines; + }; + + private maxTimestamp = (lines: DockerLogLine[]) => { + return lines.reduce((max, line) => (line.ts > max ? line.ts : max), ""); + }; + + buildDockerLogs = async (status: MonitorStatusResponse): Promise => { + const payload = typeof status.payload === "object" ? status.payload : null; + const containerLogs = payload?.logs ?? []; + if (containerLogs.length === 0) return []; + + const now = new Date(); + const checkedAt = now.toISOString(); + const expiry = new Date(now.getTime() + DOCKER_LOG_RETENTION_DAYS * MS_PER_DAY).toISOString(); + const results: DockerLog[] = []; + + for (const dockerContainerLog of containerLogs) { + const { containerId, containerName, lines } = dockerContainerLog; + try { + const lastTimestamp = await this.getLastTimestamp(status.monitorId, containerId); + const fresh = this.newLines(lines, lastTimestamp); + if (fresh.length === 0) continue; + this.lastTimestampByContainer.set(this.generateCursorKey(status.monitorId, containerId), this.maxTimestamp(fresh)); + results.push({ + id: new mongoose.Types.ObjectId().toString(), + metadata: { + monitorId: status.monitorId, + teamId: status.teamId, + containerId, + containerName, + }, + lines: fresh, + gap: fresh.length === DOCKER_LOG_TAIL_LINES, + // We only fetch DOCKER_LOG_TAIL_LINES worth of logs. + // If we have 200 fresh logs, there _could_ be more than 200 lines, so set gap = true + // Client can display that some logs may be missing + checkedAt, + expiry, + createdAt: checkedAt, + updatedAt: checkedAt, + }); + } catch (error: unknown) { + this.logger.warn({ + message: `Failed to build docker logs for container ${containerName}`, + service: SERVICE_NAME, + method: "buildDockerLogs", + details: { monitorId: status.monitorId, containerId, error: error instanceof Error ? error.message : String(error) }, + }); + } + } + return results; + }; + + createDockerLogs = async (logs: DockerLog[]): Promise => this.dockerLogsRepository.createDockerLogs(logs); +} diff --git a/server/src/domain/docker/docker-log.type.ts b/server/src/domain/docker/docker-log.type.ts new file mode 100644 index 000000000..28448f41c --- /dev/null +++ b/server/src/domain/docker/docker-log.type.ts @@ -0,0 +1,28 @@ +import { DockerLogLine } from "@/domain/docker/docker.type.js"; + +export const DOCKER_LOG_RETENTION_DAYS = 7; +export const DOCKER_LOG_PAGE_DEFAULT = 20; +export const DOCKER_LOG_PAGE_MAX = 50; + +export interface DockerLogMetadata { + monitorId: string; + teamId: string; + containerId: string; + containerName: string; +} + +export interface DockerLog { + id: string; + metadata: DockerLogMetadata; + lines: DockerLogLine[]; + gap: boolean; + checkedAt: string; + expiry: string; + createdAt: string; + updatedAt: string; +} + +export interface DockerLogPage { + logs: DockerLog[]; + nextCursor: string | null; +} diff --git a/server/src/domain/docker/docker.type.ts b/server/src/domain/docker/docker.type.ts new file mode 100644 index 000000000..66504e273 --- /dev/null +++ b/server/src/domain/docker/docker.type.ts @@ -0,0 +1,65 @@ +export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const; +export type DockerContainerState = (typeof DockerContainerStates)[number]; + +export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; +export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; + +export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const; +export type DockerPortProtocol = (typeof DockerPortProtocols)[number]; + +export const DockerLogStreams = ["stdout", "stderr"] as const; +export type DockerLogStream = (typeof DockerLogStreams)[number]; + +export const DOCKER_LOG_TAIL_LINES = 200; + +export interface DockerLogLine { + ts: string; + stream: DockerLogStream; + text: string; +} + +export interface DockerContainerLogs { + containerId: string; + containerName: string; + lines: DockerLogLine[]; +} + +export interface DockerContainerPort { + privatePort: number; + protocol: DockerPortProtocol; + publicPort?: number; + hostIp?: string; +} + +export interface DockerContainerMount { + type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, … + name?: string; + source: string; + destination: string; + mode: string; + rw: boolean; +} + +export interface DockerContainerInfo { + id: string; + name: string; + image: string; + state: DockerContainerState; + status: string; + health: DockerHealthStatus; + cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores + memoryUsedBytes?: number; + memoryLimitBytes?: number; + memoryPct?: number; // 0-1 fraction of the memory limit + restartCount?: number; + startedAt?: string; // ISO date + ports?: DockerContainerPort[]; + mounts?: DockerContainerMount[]; +} + +export interface DockerContainerSummary { + total: number; + running: number; + stopped: number; + unhealthy: number; +} diff --git a/server/src/domain/monitors/monitor.model.ts b/server/src/domain/monitors/monitor.model.ts index 89888900a..2517f5d8e 100644 --- a/server/src/domain/monitors/monitor.model.ts +++ b/server/src/domain/monitors/monitor.model.ts @@ -235,9 +235,6 @@ const MonitorSchema = new Schema( secret: { type: String, }, - sshPrivateKey: { - type: String, - }, cpuAlertThreshold: { type: Number, default: 100, @@ -306,6 +303,10 @@ const MonitorSchema = new Schema( type: Number, default: 300000, }, + dockerLogsEnabled: { + type: Boolean, + default: false, + }, dnsServer: { type: String, }, diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 9617ba3f6..db6b79853 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -436,7 +436,6 @@ class MongoMonitorsRepository implements IMonitorsRepository { tags: tagIds, customUpCodes: doc.customUpCodes ?? [], secret: doc.secret ?? undefined, - sshPrivateKey: doc.sshPrivateKey ?? undefined, cpuAlertThreshold: doc.cpuAlertThreshold, cpuAlertCounter: doc.cpuAlertCounter, memoryAlertThreshold: doc.memoryAlertThreshold, @@ -454,6 +453,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + dockerLogsEnabled: doc.dockerLogsEnabled ?? false, dnsServer: doc.dnsServer ?? undefined, dnsRecordType: doc.dnsRecordType ?? undefined, createdAt: toDateString(doc.createdAt), diff --git a/server/src/domain/monitors/monitor.service.ts b/server/src/domain/monitors/monitor.service.ts index b4a7131df..43cba16d8 100644 --- a/server/src/domain/monitors/monitor.service.ts +++ b/server/src/domain/monitors/monitor.service.ts @@ -9,6 +9,7 @@ import type { GroupedGeoCheckResult, DockerDetailsResult, DockerContainerDetailsResult, + DockerContainerLogsResult, } from "@/domain/monitors/monitor.type.js"; import { supportsGeoCheck, supportsUptimeDetails } from "@/domain/monitors/monitor.type.js"; import type { @@ -31,6 +32,7 @@ import type { ImportedMonitor } from "@/api/validation/monitorValidation.js"; import { ILogger } from "@/utils/logger.js"; import { IJobScheduler } from "@/worker/worker.interface.js"; import { DateRange } from "@/types/query.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; const SERVICE_NAME = "MonitorService"; @@ -73,6 +75,14 @@ export interface IMonitorService { containerName: string; dateRange: DateRange; }): Promise; + getDockerContainerLogs(args: { + teamId: string; + monitorId: string; + containerName: string; + before?: Date; + after?: Date; + limit: number; + }): Promise; getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; @@ -130,6 +140,7 @@ export class MonitorService implements IMonitorService { private monitorsRepository: IMonitorsRepository; private checksRepository: IChecksRepository; private geoChecksRepository: IGeoChecksRepository; + private dockerLogsRepository: IDockerLogsRepository; private monitorStatsRepository: IMonitorStatsRepository; private statusPagesRepository: IStatusPagesRepository; private incidentsRepository: IIncidentsRepository; @@ -141,6 +152,7 @@ export class MonitorService implements IMonitorService { monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository, monitorStatsRepository, statusPagesRepository, incidentsRepository, @@ -151,6 +163,7 @@ export class MonitorService implements IMonitorService { monitorsRepository: IMonitorsRepository; checksRepository: IChecksRepository; geoChecksRepository: IGeoChecksRepository; + dockerLogsRepository: IDockerLogsRepository; monitorStatsRepository: IMonitorStatsRepository; statusPagesRepository: IStatusPagesRepository; incidentsRepository: IIncidentsRepository; @@ -161,6 +174,7 @@ export class MonitorService implements IMonitorService { this.monitorsRepository = monitorsRepository; this.checksRepository = checksRepository; this.geoChecksRepository = geoChecksRepository; + this.dockerLogsRepository = dockerLogsRepository; this.monitorStatsRepository = monitorStatsRepository; this.statusPagesRepository = statusPagesRepository; this.incidentsRepository = incidentsRepository; @@ -385,6 +399,41 @@ export class MonitorService implements IMonitorService { }; }; + getDockerContainerLogs = async ({ + teamId, + monitorId, + containerName, + before, + after, + limit, + }: { + teamId: string; + monitorId: string; + containerName: string; + before?: Date; + after?: Date; + limit: number; + }): Promise => { + const monitor = await this.monitorsRepository.findById(monitorId, teamId); + if (!monitor) { + throw new AppError({ + message: `Monitor with ID ${monitorId} not found`, + status: 404, + }); + } + + if (monitor.type !== "docker") { + throw new AppError({ + message: `${monitor.type} monitors are not supported for docker container logs`, + status: 400, + }); + } + + const logs = await this.dockerLogsRepository.findByContainerName({ monitorId, containerName, before, after, limit }); + const nextCursor = !after && logs.length === limit ? (logs[logs.length - 1]?.checkedAt ?? null) : null; + return { logs, nextCursor }; + }; + getGeoChecksByMonitorId = async ({ teamId, monitorId, @@ -593,6 +642,14 @@ export class MonitorService implements IMonitorService { }); }); + await this.dockerLogsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { + this.logger.warn({ + message: `Error deleting docker logs for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err instanceof Error ? err.stack : undefined, + }); + }); + await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => { this.logger.warn({ message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`, diff --git a/server/src/domain/monitors/monitor.type.ts b/server/src/domain/monitors/monitor.type.ts index df9581131..12fcd1036 100644 --- a/server/src/domain/monitors/monitor.type.ts +++ b/server/src/domain/monitors/monitor.type.ts @@ -5,6 +5,7 @@ export type { GeoContinent } from "@/domain/geo-checks/geo-check.type.js"; import http from "node:http"; import { HardwareStats } from "@/domain/checks/check.type.js"; import { MonitorStats } from "@/domain/monitor-stats/monitor-stats.type.js"; +import { DockerLogPage } from "@/domain/docker/docker-log.type.js"; export const HttpStatusCodes = [ ...Object.keys(http.STATUS_CODES).map(Number), @@ -91,7 +92,6 @@ export interface Monitor { tags: string[]; customUpCodes: HttpStatusCode[]; secret?: string; - sshPrivateKey?: string; cpuAlertThreshold: number; cpuAlertCounter: number; memoryAlertThreshold: number; @@ -108,6 +108,7 @@ export interface Monitor { geoCheckEnabled?: boolean; geoCheckLocations?: GeoContinent[]; geoCheckInterval?: number; + dockerLogsEnabled?: boolean; dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; @@ -189,3 +190,5 @@ export interface Game { export type GamesMap = Record; export type MonitorScheduleFields = Pick; + +export type DockerContainerLogsResult = DockerLogPage; diff --git a/server/src/service/bufferService.ts b/server/src/service/bufferService.ts index 2d10aebbb..672ec174c 100755 --- a/server/src/service/bufferService.ts +++ b/server/src/service/bufferService.ts @@ -5,14 +5,18 @@ import type { ILogger } from "@/utils/logger.js"; import type { ISettingsService } from "@/domain/app-settings/app-settings.service.js"; import type { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { ICheckService } from "../domain/checks/check.service.js"; +import { DockerLog } from "@/domain/docker/docker-log.type.js"; +import { IDockerLogsService } from "@/domain/docker/docker-log.service.js"; const SERVICE_NAME = "BufferService"; export interface IBufferService { addToBuffer(check: Check): void; addGeoCheckToBuffer(geoCheck: GeoCheck): void; + addDockerLogToBuffer(dockerLog: DockerLog): void; scheduleNextFlush(): void; flushBuffer(): Promise; flushGeoBuffer(): Promise; + flushDockerLogsBuffer(): Promise; shutdown(): Promise; } @@ -23,15 +27,18 @@ export class BufferService implements IBufferService { private SERVICE_NAME: string; private buffer: Check[]; private geoBuffer: GeoCheck[]; + private dockerLogBuffer: DockerLog[]; private bufferTimer: NodeJS.Timeout | null = null; private checksService: ICheckService; private geoChecksService: IGeoChecksService; + private dockerLogsService: IDockerLogsService; private jobsRepository: IJobsRepository; constructor( logger: ILogger, checkService: ICheckService, geoChecksService: IGeoChecksService, + dockerLogsService: IDockerLogsService, settingsService: ISettingsService, jobsRepository: IJobsRepository ) { @@ -39,10 +46,12 @@ export class BufferService implements IBufferService { this.logger = logger; this.checksService = checkService; this.geoChecksService = geoChecksService; + this.dockerLogsService = dockerLogsService; this.jobsRepository = jobsRepository; this.SERVICE_NAME = SERVICE_NAME; this.buffer = []; this.geoBuffer = []; + this.dockerLogBuffer = []; this.scheduleNextFlush(); this.logger.info({ message: `Buffer service initialized, flushing every ${this.BUFFER_TIMEOUT / 1000}s`, @@ -77,6 +86,19 @@ export class BufferService implements IBufferService { } } + addDockerLogToBuffer(dockerLog: DockerLog): void { + try { + this.dockerLogBuffer.push(dockerLog); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: this.SERVICE_NAME, + method: "addDockerLogToBuffer", + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + scheduleNextFlush() { if (this.bufferTimer) { clearTimeout(this.bufferTimer); @@ -85,6 +107,7 @@ export class BufferService implements IBufferService { try { await this.flushBuffer(); await this.flushGeoBuffer(); + await this.flushDockerLogsBuffer(); } catch (error: unknown) { this.logger.error({ message: error instanceof Error ? error.message : "Unknown error", @@ -150,6 +173,30 @@ export class BufferService implements IBufferService { } } + async flushDockerLogsBuffer() { + if (this.dockerLogBuffer.length === 0) { + return; + } + // Take the batch first so a write that drains it can't be appended to mid-flush + const batch = this.dockerLogBuffer; + this.dockerLogBuffer = []; + try { + this.logger.debug({ + message: `Flushing ${batch.length} docker logs to database`, + service: this.SERVICE_NAME, + method: "flushDockerLogsBuffer", + }); + await this.dockerLogsService.createDockerLogs(batch); + } catch (error: unknown) { + this.logger.error({ + message: error instanceof Error ? error.message : "Unknown error", + service: this.SERVICE_NAME, + method: "flushDockerLogsBuffer", + stack: error instanceof Error ? error.stack : undefined, + }); + } + } + async shutdown() { if (this.bufferTimer) { clearTimeout(this.bufferTimer); @@ -157,5 +204,6 @@ export class BufferService implements IBufferService { } await this.flushBuffer(); await this.flushGeoBuffer(); + await this.flushDockerLogsBuffer(); } } diff --git a/server/src/service/network/DockerProvider.ts b/server/src/service/network/DockerProvider.ts index 8d515b604..776da3f92 100644 --- a/server/src/service/network/DockerProvider.ts +++ b/server/src/service/network/DockerProvider.ts @@ -1,6 +1,10 @@ import { IStatusProvider } from "@/service/network/IStatusProvider.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; + import { + DOCKER_LOG_TAIL_LINES, DockerContainerInfo, + DockerContainerLogs, DockerContainerMount, DockerContainerPort, DockerContainerState, @@ -8,11 +12,11 @@ import { DockerContainerSummary, DockerHealthStatus, DockerHealthStatuses, + DockerLogLine, + DockerLogStream, DockerPortProtocol, DockerPortProtocols, - DockerStatusPayload, - MonitorStatusResponse, -} from "@/types/network.js"; +} from "@/domain/docker/docker.type.js"; import { Monitor, MonitorType } from "@/domain/monitors/monitor.type.js"; import { ILogger } from "@/utils/logger.js"; import { AppError } from "@/utils/AppError.js"; @@ -23,6 +27,9 @@ type DockerodeType = typeof Dockerode; const SERVICE_NAME = "DockerProvider"; const STATS_CONCURRENCY = 5; +const DOCKER_LOG_MAX_LINE_BYTES = 4096; +const DOCKER_LOG_TRUNCATION_MARKER = " …[truncated]"; +const DOCKER_LOG_TS_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z /; export interface DockerError extends Error { statusCode?: number; @@ -56,31 +63,7 @@ export class DockerProvider implements IStatusProvider { return { socketPath }; } if (url.startsWith("/")) return { socketPath: url }; - const match = url.match(/^ssh:\/\/(?:([^@\s]+)@)?([^\s/:@]+)(?::(\d{1,5}))?\/?$/); - if (!match) throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); - const [, username, host, port] = match; - if (!username) - throw new AppError({ - message: "SSH Docker host URL requires a user: ssh://user@host", - status: 422, - service: SERVICE_NAME, - method: "toDockerOptions", - }); - - if (!monitor.sshPrivateKey) - throw new AppError({ - message: "SSH Docker host requires a private key", - status: 422, - service: SERVICE_NAME, - method: "toDockerOptions", - }); - return { - protocol: "ssh", - host, - port: port ? Number(port) : 22, - username, - sshOptions: { privateKey: monitor.sshPrivateKey }, - }; + throw new AppError({ message: `Invalid Docker host URL: ${url}`, status: 422, service: SERVICE_NAME, method: "toDockerOptions" }); }; private async mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { @@ -119,10 +102,14 @@ export class DockerProvider implements IStatusProvider { }; } + private toContainerName = (summary: Dockerode.ContainerInfo): string => { + return summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12); + }; + private async toContainerInfo(docker: Dockerode, summary: Dockerode.ContainerInfo): Promise { const info: DockerContainerInfo = { id: summary.Id, - name: summary.Names?.[0]?.replace(/^\//, "") ?? summary.Id.slice(0, 12), + name: this.toContainerName(summary), image: summary.Image, state: this.toContainerState(summary.State), status: summary.Status, @@ -198,6 +185,81 @@ export class DockerProvider implements IStatusProvider { })); } + // Log parsing + private isMultiplexed(buffer: Buffer): boolean { + return buffer.length >= 8 && buffer.readUInt8(0) <= 2 && buffer.readUInt8(1) === 0 && buffer.readUInt8(2) === 0 && buffer.readUInt8(3) === 0; + } + + // Docker will will merge stdout and stderr if stared with `-t`. + // Most containers will _not_ be run with `-t` though, in which case logs come in frames of [header][payload] + private demuxLogBuffer = (buffer: Buffer): { stream: DockerLogStream; chunk: string }[] => { + if (!this.isMultiplexed(buffer)) return [{ stream: "stdout", chunk: buffer.toString("utf8") }]; // `-t` case, log is just a string, return it + const frames: { stream: DockerLogStream; chunk: string }[] = []; + let offset = 0; // Start reading at the first byte of the first header + while (offset + 8 <= buffer.length) { + // Every 8 bytes is a header, if < 8 bytes at the end, invalid frame, stop + const streamType = buffer.readUint8(offset); // First byte of the header is the stream type: 0 = stdin, 1 = stdout, 2 = stderr + const payloadSize = buffer.readUint32BE(offset + 4); // Byte 4 to 7 describe how many payload bytes follow the header + const payloadStart = offset + 8; // Advance to the payload (header 8 bytes long) + const payloadEnd = Math.min(payloadStart + payloadSize, buffer.length); // End is the start of the payload + payload size. + frames.push({ stream: streamType === 2 ? "stderr" : "stdout", chunk: buffer.toString("utf-8", payloadStart, payloadEnd) }); + offset = payloadEnd; + } + return frames; + }; + + private truncateLog = (text: string): string => { + if (Buffer.byteLength(text, "utf8") <= DOCKER_LOG_MAX_LINE_BYTES) return text; + const cut = Buffer.from(text, "utf8").subarray(0, DOCKER_LOG_MAX_LINE_BYTES).toString("utf8").replace(/�$/, ""); + return cut + DOCKER_LOG_TRUNCATION_MARKER; + }; + + // Docker drops trailing zeroes form timestamps. Pad out timestamps for correct string comparison + private toLogLine(stream: DockerLogStream, raw: string): DockerLogLine | null { + const match = raw.match(DOCKER_LOG_TS_REGEX); + if (!match) return null; // If there's no time stamp, something is wrong with this line, drop it + const ts = `${match[1]}.${(match[2] ?? "").padEnd(9, "0")}Z`; + const text = raw.slice(match[0].length).replace(/\r$/, ""); + return { ts, stream, text: this.truncateLog(text) }; + } + + private parseLogBuffer = (buffer: Buffer): DockerLogLine[] => { + const lines: DockerLogLine[] = []; + const demuxedBuffer = this.demuxLogBuffer(buffer); + for (const byteStream of demuxedBuffer) { + const { stream, chunk } = byteStream; + for (const raw of chunk.split("\n")) { + if (raw.length === 0) continue; + const line = this.toLogLine(stream, raw); + if (line) lines.push(line); + } + } + return lines; + }; + + private toContainerLogs = async (docker: Dockerode, summary: Dockerode.ContainerInfo): Promise => { + const containerName = this.toContainerName(summary); + try { + const buffer = await docker + .getContainer(summary.Id) + .logs({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); + return { containerId: summary.Id, containerName, lines: this.parseLogBuffer(buffer) }; + } catch (error: unknown) { + this.logger.warn({ + message: `Failed to read logs for container ${containerName}`, + service: SERVICE_NAME, + method: "toContainerLogs", + details: { containerId: summary.Id, error: error instanceof Error ? error.message : String(error) }, + }); + return null; + } + }; + + private collectLogs = async (docker: Dockerode, summaries: Dockerode.ContainerInfo[]): Promise => { + const rawLogs = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerLogs(docker, s)); + return rawLogs.filter((log) => log !== null); + }; + handle = async (monitor: Monitor): Promise> => { try { const docker = new this.DockerLib(this.toDockerOptions(monitor)); @@ -226,6 +288,8 @@ export class DockerProvider implements IStatusProvider { const summaries = await docker.listContainers({ all: true }); const containers = await this.mapWithConcurrency(summaries, STATS_CONCURRENCY, (s) => this.toContainerInfo(docker, s)); + const logs = monitor.dockerLogsEnabled ? await this.collectLogs(docker, summaries) : undefined; + const summary: DockerContainerSummary = { total: containers.length, running: containers.filter((c) => c.state === "running").length, @@ -240,7 +304,7 @@ export class DockerProvider implements IStatusProvider { code: 200, message: "Docker host is reachable", responseTime, - payload: { containers, summary }, + payload: { containers, summary, logs }, }; } catch (error: unknown) { if (error instanceof AppError) throw error; diff --git a/server/src/types/network.ts b/server/src/types/network.ts index ae1cc78d2..bbbd41403 100644 --- a/server/src/types/network.ts +++ b/server/src/types/network.ts @@ -9,6 +9,7 @@ import type { GotTimings, ILighthouseAudit, } from "@/domain/checks/check.type.js"; +import { DockerContainerInfo, DockerContainerLogs, DockerContainerSummary } from "@/domain/docker/docker.type.js"; import type { DnsRecordType, Monitor, MonitorMatchMethod, MonitorStatus, MonitorType } from "@/domain/monitors/monitor.type.js"; import type { QueryResult } from "gamedig"; @@ -97,58 +98,11 @@ export interface HardwareStatusPayload { } // Docker host monitoring -export const DockerContainerStates = ["created", "running", "paused", "restarting", "removing", "exited", "dead"] as const; -export type DockerContainerState = (typeof DockerContainerStates)[number]; - -export const DockerHealthStatuses = ["healthy", "unhealthy", "starting", "none"] as const; -export type DockerHealthStatus = (typeof DockerHealthStatuses)[number]; - -export interface DockerContainerSummary { - total: number; - running: number; - stopped: number; - unhealthy: number; -} - -export interface DockerContainerInfo { - id: string; - name: string; - image: string; - state: DockerContainerState; - status: string; - health: DockerHealthStatus; - cpuPct?: number; // fraction of one core (docker stats convention / 100); exceeds 1 when using multiple cores - memoryUsedBytes?: number; - memoryLimitBytes?: number; - memoryPct?: number; // 0-1 fraction of the memory limit - restartCount?: number; - startedAt?: string; // ISO date - ports?: DockerContainerPort[]; - mounts?: DockerContainerMount[]; -} - -export const DockerPortProtocols = ["tcp", "udp", "sctp"] as const; -export type DockerPortProtocol = (typeof DockerPortProtocols)[number]; - -export interface DockerContainerPort { - privatePort: number; - protocol: DockerPortProtocol; - publicPort?: number; - hostIp?: string; -} - -export interface DockerContainerMount { - type: string; // open set: bind, volume, tmpfs, image, npipe, cluster, … - name?: string; - source: string; - destination: string; - mode: string; - rw: boolean; -} export interface DockerStatusPayload { containers: DockerContainerInfo[]; summary: DockerContainerSummary; + logs?: DockerContainerLogs[]; } export interface PortStatusPayload { diff --git a/server/src/worker/worker.check-producer.ts b/server/src/worker/worker.check-producer.ts index f297d01be..404978aef 100644 --- a/server/src/worker/worker.check-producer.ts +++ b/server/src/worker/worker.check-producer.ts @@ -1,6 +1,6 @@ import { Monitor } from "@/domain/monitors/monitor.type.js"; import { Check } from "@/domain/checks/check.type.js"; -import { MonitorStatusResponse } from "@/types/network.js"; +import { DockerStatusPayload, MonitorStatusResponse } from "@/types/network.js"; import { AppError } from "@/utils/AppError.js"; import { ILogger } from "@/utils/logger.js"; import { IBufferService } from "@/service/bufferService.js"; @@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf import { IMaintenanceWindowsRepository } from "@/domain/maintenance-windows/maintenance-window.repository.interface.js"; import { isWindowActive } from "@/utils/maintenanceWindow.js"; import { IProxyResolver } from "@/service/network/ProxyResolver.js"; +import { IDockerLogsService } from "@/domain/docker/docker-log.service.js"; export interface ICheckProducer { produce(monitor: Monitor): Promise<{ status: MonitorStatusResponse; check: Check } | null>; @@ -25,6 +26,7 @@ export class CheckProducer implements ICheckProducer { private networkService: INetworkService, private proxyResolver: IProxyResolver, private bufferService: IBufferService, + private dockerLogsService: IDockerLogsService, private logger: ILogger ) {} @@ -81,6 +83,14 @@ export class CheckProducer implements ICheckProducer { } // Step 2b: Add to buffer this.bufferService.addToBuffer(check); + + // Step 2c: Handle docker logs + if (status.type === "docker") { + const dockerLogs = await this.dockerLogsService.buildDockerLogs(status as MonitorStatusResponse); + for (const dockerLog of dockerLogs) { + this.bufferService.addDockerLogToBuffer(dockerLog); + } + } return { status, check }; }; } diff --git a/server/src/worker/worker.helper.ts b/server/src/worker/worker.helper.ts index 9f044763d..d8a35e0b2 100644 --- a/server/src/worker/worker.helper.ts +++ b/server/src/worker/worker.helper.ts @@ -10,6 +10,7 @@ import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interf import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js"; import { ITeamsRepository } from "@/domain/teams/team.repository.interface.js"; import { ILogger } from "@/utils/logger.js"; +import { IDockerLogsRepository } from "@/domain/docker/docker-log.repository.interface.js"; export interface IWorkerHelper { getCleanupOrphanedJob(): () => Promise; @@ -43,6 +44,7 @@ export class WorkerHelper implements IWorkerHelper { private checksRepository: IChecksRepository; private incidentsRepository: IIncidentsRepository; private geoChecksRepository: IGeoChecksRepository; + private dockerLogsRepository: IDockerLogsRepository; constructor( logger: ILogger, @@ -54,7 +56,8 @@ export class WorkerHelper implements IWorkerHelper { monitorStatsRepository: IMonitorStatsRepository, checksRepository: IChecksRepository, incidentsRepository: IIncidentsRepository, - geoChecksRepository: IGeoChecksRepository + geoChecksRepository: IGeoChecksRepository, + dockerLogsRepository: IDockerLogsRepository ) { this.logger = logger; this.checkService = checkService; @@ -66,6 +69,7 @@ export class WorkerHelper implements IWorkerHelper { this.checksRepository = checksRepository; this.incidentsRepository = incidentsRepository; this.geoChecksRepository = geoChecksRepository; + this.dockerLogsRepository = dockerLogsRepository; } getCleanupOrphanedJob = () => { @@ -142,6 +146,15 @@ export class WorkerHelper implements IWorkerHelper { }); } + const deletedDockerLogsCount = await this.dockerLogsRepository.deleteByMonitorIdsNotIn(allMonitorIds); + if (deletedDockerLogsCount > 0) { + this.logger.info({ + message: `Deleted ${deletedDockerLogsCount} orphaned docker logs`, + service: SERVICE_NAME, + method: "getCleanupOrphanedJob", + }); + } + // Remove orphaned jobs const deletedJobsCount = await this.jobsRepository.deleteByMonitorIdsNotIn(allMonitorIds); if (deletedJobsCount > 0) { diff --git a/server/test/helpers/heartbeatTestHarness.ts b/server/test/helpers/heartbeatTestHarness.ts index 9c2161790..0661d6445 100644 --- a/server/test/helpers/heartbeatTestHarness.ts +++ b/server/test/helpers/heartbeatTestHarness.ts @@ -128,6 +128,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) }; const proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) }; + const dockerLogsService = { buildDockerLogs: jest.fn().mockResolvedValue([]) }; const checkProducer = new CheckProducer( monitorsRepo as any, @@ -136,6 +137,7 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness { networkService as any, proxyResolver as any, bufferStub as any, + dockerLogsService as any, logger ); const checkEvaluator = new CheckEvaluator(statusService as any, new MonitorStatusPolicy()); diff --git a/server/test/unit/providers/network/dockerProvider.test.ts b/server/test/unit/providers/network/dockerProvider.test.ts index 4a9e3fe52..4c27985ef 100644 --- a/server/test/unit/providers/network/dockerProvider.test.ts +++ b/server/test/unit/providers/network/dockerProvider.test.ts @@ -5,11 +5,10 @@ import { createMockLogger } from "../../../helpers/createMockLogger.ts"; import { NETWORK_ERROR } from "../../../../src/types/network.ts"; import { AppError } from "../../../../src/utils/AppError.ts"; import type { Monitor } from "../../../../src/domain/monitors/monitor.type.ts"; +import { DOCKER_LOG_TAIL_LINES } from "../../../../src/domain/docker/docker.type.ts"; // ── Fixtures ───────────────────────────────────────────────────────────────── -const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; - const makeMonitor = (overrides?: Partial): Monitor => ({ id: "mon-1", @@ -53,10 +52,19 @@ const makeStats = (overrides?: Record) => ({ ...overrides, }); -const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any }> = {}) => { +const makeLogFrame = (streamType: number, text: string): Buffer => { + const payload = Buffer.from(text); + const header = Buffer.alloc(8); + header.writeUInt8(streamType, 0); + header.writeUInt32BE(payload.length, 4); + return Buffer.concat([header, payload]); +}; + +const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer: any; inspect: any; stats: any; logs: any }> = {}) => { const inspect = overrides.inspect ?? jest.fn().mockResolvedValue(makeInspect()); const stats = overrides.stats ?? jest.fn().mockResolvedValue(makeStats()); - const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats }); + const logs = overrides.logs ?? jest.fn().mockResolvedValue(Buffer.alloc(0)); + const getContainer = overrides.getContainer ?? jest.fn().mockReturnValue({ inspect, stats, logs }); const instance = { ping: overrides.ping ?? jest.fn().mockResolvedValue("OK"), listContainers: overrides.listContainers ?? jest.fn().mockResolvedValue([makeContainer()]), @@ -65,7 +73,7 @@ const setup = (overrides: Partial<{ ping: any; listContainers: any; getContainer const DockerLib = jest.fn().mockReturnValue(instance) as any; const logger = createMockLogger(); const provider = new DockerProvider(logger as any, DockerLib); - return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats }; + return { provider, logger, DockerLib, ping: instance.ping, listContainers: instance.listContainers, getContainer, inspect, stats, logs }; }; // ── Contract ───────────────────────────────────────────────────────────────── @@ -106,28 +114,6 @@ describe("DockerProvider", () => { expect(DockerLib).toHaveBeenCalledWith({ socketPath: "/var/run/docker.sock" }); }); - - it("parses ssh urls with a private key, defaulting to port 22", async () => { - const { provider, DockerLib } = setup(); - - await provider.handle(makeMonitor({ url: "ssh://deploy@prod-swarm-01.internal", sshPrivateKey: PRIVATE_KEY })); - - expect(DockerLib).toHaveBeenCalledWith({ - protocol: "ssh", - host: "prod-swarm-01.internal", - port: 22, - username: "deploy", - sshOptions: { privateKey: PRIVATE_KEY }, - }); - }); - - it("parses an explicit ssh port", async () => { - const { provider, DockerLib } = setup(); - - await provider.handle(makeMonitor({ url: "ssh://deploy@host:2222", sshPrivateKey: PRIVATE_KEY })); - - expect(DockerLib).toHaveBeenCalledWith(expect.objectContaining({ port: 2222 })); - }); }); // ── Fail-loudly url validation ─────────────────────────────────────── @@ -141,7 +127,7 @@ describe("DockerProvider", () => { ["a container name (old semantics)", "my-container", "Invalid Docker host URL"], ["tcp engine urls (unsupported)", "tcp://host:2375", "Invalid Docker host URL"], ["https engine urls (unsupported)", "https://host", "Invalid Docker host URL"], - ["ssh without a user", "ssh://host", "SSH Docker host URL requires a user: ssh://user@host"], + ["ssh engine urls (unsupported)", "ssh://deploy@host", "Invalid Docker host URL"], ]; it.each(invalidUrls)("throws AppError for %s and never constructs a client", async (_label, url, message) => { @@ -151,15 +137,6 @@ describe("DockerProvider", () => { expect(DockerLib).not.toHaveBeenCalled(); }); - it("throws AppError for an ssh url with no private key", async () => { - const { provider, DockerLib } = setup(); - - await expect(provider.handle(makeMonitor({ url: "ssh://deploy@host", sshPrivateKey: undefined }))).rejects.toThrow( - "SSH Docker host requires a private key" - ); - expect(DockerLib).not.toHaveBeenCalled(); - }); - it("preserves the toDockerOptions AppError through handle's catch", async () => { const { provider } = setup(); @@ -278,6 +255,83 @@ describe("DockerProvider", () => { }); }); + describe("container logs", () => { + it("parses multiplexed stdout and stderr with normalized timestamps", async () => { + const logs = jest + .fn() + .mockResolvedValue( + Buffer.concat([makeLogFrame(1, "2026-01-01T00:00:37.1Z ready\n"), makeLogFrame(2, "2026-01-01T00:00:38.123Z failed\r\n")]) + ); + const { provider } = setup({ logs }); + + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); + + expect(result.payload?.logs?.[0]?.lines).toEqual([ + { ts: "2026-01-01T00:00:37.100000000Z", stream: "stdout", text: "ready" }, + { ts: "2026-01-01T00:00:38.123000000Z", stream: "stderr", text: "failed" }, + ]); + }); + + it("treats raw TTY output as stdout and drops lines without timestamps", async () => { + const logs = jest.fn().mockResolvedValue(Buffer.from("noise\n2026-01-01T00:00:00Z first\n2026-01-01T00:00:01.12Z second\n")); + const { provider } = setup({ logs }); + + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); + + expect(result.payload?.logs?.[0]?.lines).toEqual([ + { ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "first" }, + { ts: "2026-01-01T00:00:01.120000000Z", stream: "stdout", text: "second" }, + ]); + }); + + it("truncates oversized UTF-8 lines and appends the marker", async () => { + const logs = jest.fn().mockResolvedValue(Buffer.from(`2026-01-01T00:00:00Z ${"x".repeat(5000)}\n`)); + const { provider } = setup({ logs }); + + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); + const text = result.payload?.logs?.[0]?.lines[0]?.text; + expect(text).toEqual(expect.stringMatching(/ …\[truncated\]$/)); + expect(Buffer.byteLength(text?.replace(" …[truncated]", "") ?? "", "utf8")).toBeLessThanOrEqual(4096); + }); + + it("isolates a logs failure and warns", async () => { + const { provider, logger } = setup({ logs: jest.fn().mockRejectedValue(new Error("logs failed")) }); + + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: true })); + + expect(result.payload?.logs).toEqual([]); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to read logs for container my-container" })); + }); + + it("requests a bounded timestamped tail for every container state", async () => { + const containers = [makeContainer({ Id: "a".repeat(64) }), makeContainer({ Id: "b".repeat(64), State: "exited" })]; + const { provider, logs } = setup({ listContainers: jest.fn().mockResolvedValue(containers) }); + + await provider.handle(makeMonitor({ dockerLogsEnabled: true })); + + expect(logs).toHaveBeenCalledTimes(2); + expect(logs).toHaveBeenCalledWith({ follow: false, stdout: true, stderr: true, timestamps: true, tail: DOCKER_LOG_TAIL_LINES }); + }); + + it("does not read logs when dockerLogsEnabled is false", async () => { + const { provider, logs } = setup(); + + const result = await provider.handle(makeMonitor({ dockerLogsEnabled: false })); + + expect(logs).not.toHaveBeenCalled(); + expect(result.payload?.logs).toBeUndefined(); + }); + + it("does not read logs when dockerLogsEnabled is unset", async () => { + const { provider, logs } = setup(); + + const result = await provider.handle(makeMonitor()); + + expect(logs).not.toHaveBeenCalled(); + expect(result.payload?.logs).toBeUndefined(); + }); + }); + // ── Enrichment fault isolation ─────────────────────────────────────── describe("enrichment fault isolation", () => { diff --git a/server/test/unit/services/bufferService.test.ts b/server/test/unit/services/bufferService.test.ts index 90b5489bf..d193c7b3d 100644 --- a/server/test/unit/services/bufferService.test.ts +++ b/server/test/unit/services/bufferService.test.ts @@ -7,6 +7,8 @@ import type { ISettingsService } from "../../../src/domain/app-settings/app-sett import type { IJobsRepository } from "../../../src/domain/jobs/job.repository.interface.ts"; import type { Check } from "../../../src/domain/checks/check.type.ts"; import type { GeoCheck } from "../../../src/domain/geo-checks/geo-check.type.ts"; +import type { IDockerLogsService } from "../../../src/domain/docker/docker-log.service.ts"; +import type { DockerLog } from "../../../src/domain/docker/docker-log.type.ts"; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -20,6 +22,11 @@ const createMockGeoChecksService = () => createGeoChecks: jest.fn().mockResolvedValue([]), }) as unknown as jest.Mocked; +const createMockDockerLogsService = () => + ({ + createDockerLogs: jest.fn().mockResolvedValue(0), + }) as unknown as jest.Mocked; + const createMockSettingsService = (nodeEnv: string = "development") => ({ getSettings: jest.fn().mockReturnValue({ nodeEnv }), @@ -48,14 +55,28 @@ const makeGeoCheck = (overrides?: Partial): GeoCheck => ...overrides, }) as GeoCheck; +const makeDockerLog = (overrides?: Partial): DockerLog => + ({ + id: "docker-log-1", + metadata: { monitorId: "mon-1", teamId: "team-1", containerId: "container-1", containerName: "web" }, + lines: [{ ts: "2026-01-01T00:00:00.000000000Z", stream: "stdout", text: "ready" }], + gap: false, + checkedAt: "2026-01-01T00:00:01.000Z", + expiry: "2026-01-08T00:00:01.000Z", + createdAt: "2026-01-01T00:00:01.000Z", + updatedAt: "2026-01-01T00:00:01.000Z", + ...overrides, + }) as DockerLog; + const createService = (nodeEnv: string = "development") => { const logger = createMockLogger(); const checkService = createMockCheckService(); const geoChecksService = createMockGeoChecksService(); + const dockerLogsService = createMockDockerLogsService(); const settingsService = createMockSettingsService(nodeEnv); const jobsRepository = createMockJobsRepository(); - const service = new BufferService(logger as any, checkService, geoChecksService, settingsService, jobsRepository); - return { service, logger, checkService, geoChecksService, jobsRepository }; + const service = new BufferService(logger as any, checkService, geoChecksService, dockerLogsService, settingsService, jobsRepository); + return { service, logger, checkService, geoChecksService, dockerLogsService, jobsRepository }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -209,6 +230,27 @@ describe("BufferService", () => { }); }); + describe("addDockerLogToBuffer", () => { + it("adds a docker log to the buffer", async () => { + const { service, dockerLogsService } = createService(); + const dockerLog = makeDockerLog(); + + service.addDockerLogToBuffer(dockerLog); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); + }); + + it("logs an error if push throws", () => { + const { service, logger } = createService(); + Object.defineProperty(service, "dockerLogBuffer", { value: Object.freeze([]) }); + + service.addDockerLogToBuffer(makeDockerLog()); + + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ method: "addDockerLogToBuffer" })); + }); + }); + // ── scheduleNextFlush ──────────────────────────────────────────────── describe("scheduleNextFlush", () => { @@ -223,15 +265,17 @@ describe("BufferService", () => { expect(jest.getTimerCount()).toBe(initialTimerCount); }); - it("flushes buffer and geo buffer when timer fires", async () => { - const { service, checkService, geoChecksService } = createService(); + it("flushes all buffers when timer fires", async () => { + const { service, checkService, geoChecksService, dockerLogsService } = createService(); service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); + service.addDockerLogToBuffer(makeDockerLog()); await jest.advanceTimersByTimeAsync(1000); expect(checkService.createChecks).toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).toHaveBeenCalled(); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalled(); }); it("reschedules after flush completes", async () => { @@ -472,18 +516,72 @@ describe("BufferService", () => { }); }); + describe("flushDockerLogsBuffer", () => { + it("does nothing when the docker log buffer is empty", async () => { + const { service, dockerLogsService } = createService(); + + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled(); + }); + + it("flushes the batch and clears it", async () => { + const { service, dockerLogsService } = createService(); + const dockerLog = makeDockerLog(); + service.addDockerLogToBuffer(dockerLog); + + await service.flushDockerLogsBuffer(); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledWith([dockerLog]); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); + }); + + it("does not drop logs added while a flush is in flight", async () => { + const { service, dockerLogsService } = createService(); + let resolveWrite!: (value: number) => void; + (dockerLogsService.createDockerLogs as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => (resolveWrite = resolve))); + const first = makeDockerLog({ id: "docker-log-1" }); + const second = makeDockerLog({ id: "docker-log-2" }); + service.addDockerLogToBuffer(first); + + const flush = service.flushDockerLogsBuffer(); + service.addDockerLogToBuffer(second); + resolveWrite(1); + await flush; + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(1, [first]); + expect(dockerLogsService.createDockerLogs).toHaveBeenNthCalledWith(2, [second]); + }); + + it("drops a failed batch and logs the error", async () => { + const { service, dockerLogsService, logger } = createService(); + (dockerLogsService.createDockerLogs as jest.Mock).mockRejectedValueOnce(new Error("DB error")); + service.addDockerLogToBuffer(makeDockerLog()); + + await service.flushDockerLogsBuffer(); + await service.flushDockerLogsBuffer(); + + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "DB error", method: "flushDockerLogsBuffer" })); + }); + }); + // ── shutdown ───────────────────────────────────────────────────────────── describe("shutdown", () => { - it("stops the flush timer and flushes both buffers", async () => { - const { service, checkService, geoChecksService } = createService(); + it("stops the flush timer and flushes all buffers", async () => { + const { service, checkService, geoChecksService, dockerLogsService } = createService(); service.addToBuffer(makeCheck()); service.addGeoCheckToBuffer(makeGeoCheck()); + service.addDockerLogToBuffer(makeDockerLog()); await service.shutdown(); expect(checkService.createChecks).toHaveBeenCalledTimes(1); expect(geoChecksService.createGeoChecks).toHaveBeenCalledTimes(1); + expect(dockerLogsService.createDockerLogs).toHaveBeenCalledTimes(1); // Timer is cleared: advancing past the flush interval triggers no further flush. await jest.advanceTimersByTimeAsync(60 * 1000); @@ -491,12 +589,13 @@ describe("BufferService", () => { }); it("is safe to call with empty buffers", async () => { - const { service, checkService, geoChecksService } = createService(); + const { service, checkService, geoChecksService, dockerLogsService } = createService(); await service.shutdown(); expect(checkService.createChecks).not.toHaveBeenCalled(); expect(geoChecksService.createGeoChecks).not.toHaveBeenCalled(); + expect(dockerLogsService.createDockerLogs).not.toHaveBeenCalled(); }); }); }); diff --git a/server/test/unit/services/checkProducer.test.ts b/server/test/unit/services/checkProducer.test.ts index 2a1aa29f1..7116c5adb 100644 --- a/server/test/unit/services/checkProducer.test.ts +++ b/server/test/unit/services/checkProducer.test.ts @@ -28,6 +28,7 @@ const createProducer = (overrides?: Record) => { networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) }, proxyResolver: { resolve: jest.fn().mockResolvedValue(undefined) }, buffer: { addToBuffer: jest.fn() }, + dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue([]) }, ...overrides, }; const producer = new CheckProducer( @@ -37,6 +38,7 @@ const createProducer = (overrides?: Record) => { defaults.networkService as any, defaults.proxyResolver as any, defaults.buffer as any, + defaults.dockerLogsService as any, defaults.logger as any ); return { producer, defaults }; @@ -142,4 +144,28 @@ describe("CheckProducer", () => { expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith(check); expect(result).toEqual({ status, check }); }); + + it("builds and buffers docker logs for a docker status", async () => { + const status = { type: "docker", monitorId: "m1", teamId: "team", status: true, code: 200, message: "OK", payload: {} }; + const dockerLogs = [{ id: "docker-log-1" }, { id: "docker-log-2" }]; + const { producer, defaults } = createProducer({ + networkService: { requestStatus: jest.fn().mockResolvedValue(status) }, + buffer: { addToBuffer: jest.fn(), addDockerLogToBuffer: jest.fn() }, + dockerLogsService: { buildDockerLogs: jest.fn().mockResolvedValue(dockerLogs) }, + }); + + await producer.produce(makeMonitor({ type: "docker" })); + + expect(defaults.dockerLogsService.buildDockerLogs).toHaveBeenCalledWith(status); + expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(1, dockerLogs[0]); + expect(defaults.buffer.addDockerLogToBuffer).toHaveBeenNthCalledWith(2, dockerLogs[1]); + }); + + it("does not build docker logs for a non-docker status", async () => { + const { producer, defaults } = createProducer(); + + await producer.produce(makeMonitor()); + + expect(defaults.dockerLogsService.buildDockerLogs).not.toHaveBeenCalled(); + }); }); diff --git a/server/test/unit/services/dockerLogsService.test.ts b/server/test/unit/services/dockerLogsService.test.ts new file mode 100644 index 000000000..9e27a84e1 --- /dev/null +++ b/server/test/unit/services/dockerLogsService.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { DockerLogsService } from "../../../src/domain/docker/docker-log.service.ts"; +import { DOCKER_LOG_TAIL_LINES, type DockerLogLine } from "../../../src/domain/docker/docker.type.ts"; +import type { MonitorStatusResponse, DockerStatusPayload } from "../../../src/types/network.ts"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; + +const line = (second: number): DockerLogLine => ({ + ts: `2026-01-01T00:00:${String(second).padStart(2, "0")}.000000000Z`, + stream: "stdout", + text: `line ${second}`, +}); + +const status = (logs?: DockerStatusPayload["logs"]): MonitorStatusResponse => ({ + monitorId: "monitor-1", + teamId: "team-1", + type: "docker", + status: true, + code: 200, + message: "OK", + payload: { containers: [], summary: { total: 0, running: 0, stopped: 0, unhealthy: 0 }, logs }, +}); + +const createService = () => { + const repository = { + findLastLineTimestamp: jest.fn().mockResolvedValue(null), + createDockerLogs: jest.fn().mockResolvedValue(0), + }; + const logger = createMockLogger(); + return { service: new DockerLogsService({ logger: logger as any, dockerLogsRepository: repository as any }), repository, logger }; +}; + +describe("DockerLogsService", () => { + it("returns no documents for missing or empty logs", async () => { + const { service, repository } = createService(); + expect(await service.buildDockerLogs(status())).toEqual([]); + expect(await service.buildDockerLogs(status([]))).toEqual([]); + expect(repository.findLastLineTimestamp).not.toHaveBeenCalled(); + }); + + it("seeds the cursor, drops old lines, and sets seven-day expiry", async () => { + const { service, repository } = createService(); + repository.findLastLineTimestamp.mockResolvedValue(line(1).ts); + + const result = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines: [line(1), line(2)] }])); + + expect(repository.findLastLineTimestamp).toHaveBeenCalledWith("monitor-1", "container-1"); + expect(result[0]?.lines).toEqual([line(2)]); + expect(new Date(result[0]!.expiry).getTime() - new Date(result[0]!.checkedAt).getTime()).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it("advances the cursor in memory and does not query again", async () => { + const { service, repository } = createService(); + const logs = [{ containerId: "container-1", containerName: "web", lines: [line(1)] }]; + + expect(await service.buildDockerLogs(status(logs))).toHaveLength(1); + expect(await service.buildDockerLogs(status(logs))).toEqual([]); + expect(repository.findLastLineTimestamp).toHaveBeenCalledTimes(1); + }); + + it("sets gap only when every tail line is fresh", async () => { + const { service } = createService(); + const lines = Array.from({ length: DOCKER_LOG_TAIL_LINES }, (_, index) => ({ + ts: `2026-01-01T00:00:00.${String(index).padStart(9, "0")}Z`, + stream: "stdout" as const, + text: String(index), + })); + + const [result] = await service.buildDockerLogs(status([{ containerId: "container-1", containerName: "web", lines }])); + expect(result?.gap).toBe(true); + }); + + it("skips a failed container and continues with the others", async () => { + const { service, repository, logger } = createService(); + repository.findLastLineTimestamp.mockRejectedValueOnce(new Error("DB down")).mockResolvedValueOnce(null); + + const result = await service.buildDockerLogs( + status([ + { containerId: "bad", containerName: "bad-container", lines: [line(1)] }, + { containerId: "good", containerName: "good-container", lines: [line(2)] }, + ]) + ); + + expect(result).toHaveLength(1); + expect(result[0]?.metadata.containerId).toBe("good"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Failed to build docker logs for container bad-container" })); + }); +}); diff --git a/server/test/unit/services/monitorService.test.ts b/server/test/unit/services/monitorService.test.ts index 79753e41c..1bb4b3423 100644 --- a/server/test/unit/services/monitorService.test.ts +++ b/server/test/unit/services/monitorService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, jest, beforeEach } from "@jest/globals"; import { MonitorService } from "../../../src/domain/monitors/monitor.service.ts"; import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; import type { IGeoChecksRepository } from "../../../src/domain/geo-checks/geo-check.repository.interface.ts"; +import type { IDockerLogsRepository } from "../../../src/domain/docker/docker-log.repository.interface.ts"; import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts"; import type { IMonitorStatsRepository } from "../../../src/domain/monitor-stats/monitor-stats.repository.interface.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; @@ -50,6 +51,12 @@ const createGeoChecksRepositoryMock = () => deleteByMonitorId: jest.fn(), }) as unknown as IGeoChecksRepository; +const createDockerLogsRepositoryMock = () => + ({ + findByContainerName: jest.fn(), + deleteByMonitorId: jest.fn().mockResolvedValue(0), + }) as unknown as IDockerLogsRepository; + const createIncidentsRepositoryMock = () => ({ deleteByMonitorId: jest.fn(), @@ -89,6 +96,7 @@ const createService = ( monitorStatsRepository?: IMonitorStatsRepository; statusPagesRepository?: IStatusPagesRepository; geoChecksRepository?: IGeoChecksRepository; + dockerLogsRepository?: IDockerLogsRepository; incidentsRepository?: IIncidentsRepository; jobQueue?: ReturnType; logger?: ReturnType; @@ -104,6 +112,7 @@ const createService = ( monitorsRepository: overrides.monitorsRepository ?? createMonitorsRepositoryMock(), checksRepository: overrides.checksRepository ?? createChecksRepositoryMock(), geoChecksRepository: overrides.geoChecksRepository ?? createGeoChecksRepositoryMock(), + dockerLogsRepository: overrides.dockerLogsRepository ?? createDockerLogsRepositoryMock(), monitorStatsRepository: overrides.monitorStatsRepository ?? createMonitorStatsRepositoryMock(), statusPagesRepository: overrides.statusPagesRepository ?? createStatusPagesRepositoryMock(), incidentsRepository: overrides.incidentsRepository ?? createIncidentsRepositoryMock(), @@ -594,6 +603,83 @@ describe("MonitorService", () => { }); }); + describe("getDockerContainerLogs", () => { + it("throws 404 when the monitor is not found", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(null); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: "missing", containerName: "web", limit: 20 })).rejects.toThrow( + "Monitor with ID missing not found" + ); + }); + + it("throws 400 for a non-docker monitor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "http" })); + const { service } = createService({ monitorsRepository }); + + await expect(service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 20 })).rejects.toThrow( + "monitors are not supported for docker container logs" + ); + }); + + it("passes pagination through and returns the last checkedAt as a full-page cursor", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + const before = new Date("2026-01-02T00:00:00.000Z"); + const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }]; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "web", + before, + limit: 2, + }); + + expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", before, limit: 2 }); + expect(result.nextCursor).toBe("2026-01-01T01:00:00.000Z"); + }); + + it("returns a null cursor for a short page", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue([{ checkedAt: "2026-01-01T01:00:00.000Z" }]); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ teamId: TEAM_ID, monitorId: MONITOR_ID, containerName: "web", limit: 2 }); + + expect(result.nextCursor).toBeNull(); + }); + + it("passes after through and returns a null cursor even for a full page", async () => { + const monitorsRepository = createMonitorsRepositoryMock(); + const dockerLogsRepository = createDockerLogsRepositoryMock(); + const after = new Date("2026-01-01T00:00:00.000Z"); + const logs = [{ checkedAt: "2026-01-01T02:00:00.000Z" }, { checkedAt: "2026-01-01T01:00:00.000Z" }]; + (monitorsRepository.findById as jest.Mock).mockResolvedValue(makeMonitor({ type: "docker" })); + (dockerLogsRepository.findByContainerName as jest.Mock).mockResolvedValue(logs); + const { service } = createService({ monitorsRepository, dockerLogsRepository }); + + const result = await service.getDockerContainerLogs({ + teamId: TEAM_ID, + monitorId: MONITOR_ID, + containerName: "web", + after, + limit: 2, + }); + + expect(dockerLogsRepository.findByContainerName).toHaveBeenCalledWith({ monitorId: MONITOR_ID, containerName: "web", after, limit: 2 }); + expect(result.logs).toBe(logs); + expect(result.nextCursor).toBeNull(); + }); + }); + describe("getPageSpeedDetailsById", () => { it("returns pagespeed details for a pagespeed monitor", async () => { const monitorsRepository = createMonitorsRepositoryMock(); @@ -1802,6 +1888,7 @@ describe("MonitorService", () => { monitorsRepository, checksRepository, geoChecksRepository, + dockerLogsRepository: createDockerLogsRepositoryMock(), monitorStatsRepository, statusPagesRepository, incidentsRepository, diff --git a/server/test/unit/services/workerHelper.test.ts b/server/test/unit/services/workerHelper.test.ts index fe7dca42d..749804768 100644 --- a/server/test/unit/services/workerHelper.test.ts +++ b/server/test/unit/services/workerHelper.test.ts @@ -32,6 +32,9 @@ const createHelper = (overrides?: Record) => { const geoChecksRepository = { deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), }; + const dockerLogsRepository = { + deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0), + }; const settingsService = { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }), }; @@ -50,6 +53,7 @@ const createHelper = (overrides?: Record) => { checksRepository, incidentsRepository, geoChecksRepository, + dockerLogsRepository, ...overrides, }; @@ -63,7 +67,8 @@ const createHelper = (overrides?: Record) => { defaults.monitorStatsRepository as any, defaults.checksRepository as any, defaults.incidentsRepository as any, - defaults.geoChecksRepository as any + defaults.geoChecksRepository as any, + defaults.dockerLogsRepository as any ); return { helper, defaults }; }; @@ -81,6 +86,7 @@ describe("WorkerHelper", () => { (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + (defaults.dockerLogsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(7); (defaults.jobsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(6); const job = helper.getCleanupOrphanedJob(); @@ -93,12 +99,14 @@ describe("WorkerHelper", () => { expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + expect(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.jobsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("7 orphaned docker logs") })); expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("6 orphaned jobs") })); }); diff --git a/server/test/unit/validation/monitorValidation.test.ts b/server/test/unit/validation/monitorValidation.test.ts index 2b6f994d7..1cd224ab5 100644 --- a/server/test/unit/validation/monitorValidation.test.ts +++ b/server/test/unit/validation/monitorValidation.test.ts @@ -3,8 +3,31 @@ import { createMonitorBodyValidation, editMonitorBodyValidation, importMonitorsBodyValidation, + getDockerContainerLogsQueryValidation, } from "../../../src/api/validation/monitorValidation.ts"; +describe("getDockerContainerLogsQueryValidation", () => { + it("defaults limit to 20", () => { + expect(getDockerContainerLogsQueryValidation.parse({})).toEqual({ limit: 20 }); + }); + + it("rejects a limit above 50", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ limit: 51 })).toThrow(); + }); + + it("rejects a non-ISO before cursor", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ before: "yesterday" })).toThrow(); + }); + + it("rejects a non-ISO after cursor", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ after: "yesterday" })).toThrow(); + }); + + it("rejects before and after together", () => { + expect(() => getDockerContainerLogsQueryValidation.parse({ before: "2026-01-02T00:00:00.000Z", after: "2026-01-01T00:00:00.000Z" })).toThrow(); + }); +}); + const baseDnsBody = { name: "DNS check", type: "dns" as const, @@ -210,6 +233,19 @@ describe("monitorValidation — strategy gating", () => { expect(parsed.monitors[0].strategy).toBeUndefined(); }); + it("defaults dockerLogsEnabled to false on imported docker monitors", () => { + const parsed = importMonitorsBodyValidation.parse({ + monitors: [ + { + name: "Imported Docker", + type: "docker", + url: "unix:///var/run/docker.sock", + }, + ], + }); + expect(parsed.monitors[0].dockerLogsEnabled).toBe(false); + }); + it("rejects strategy on imported non-pagespeed monitors", () => { expect(() => importMonitorsBodyValidation.parse({ @@ -572,7 +608,6 @@ describe("monitorValidation — Docker host url", () => { type: "docker" as const, url: "unix:///var/run/docker.sock", }; - const PRIVATE_KEY = "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----"; describe("createMonitorBodyValidation", () => { it("accepts the default unix socket url", () => { @@ -585,32 +620,15 @@ describe("monitorValidation — Docker host url", () => { expect(parsed.url).toBe("/run/docker.sock"); }); - it("accepts an ssh url with a private key and retains the key", () => { - for (const url of ["ssh://deploy@host", "ssh://deploy@host:2222"]) { - const parsed = createMonitorBodyValidation.parse({ ...baseDockerBody, url, sshPrivateKey: PRIVATE_KEY }); - expect(parsed.sshPrivateKey).toBe(PRIVATE_KEY); - } - }); - it("rejects container names and unsupported engine urls", () => { - for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "unix://relative/path", ""]) { + for (const badUrl of ["my-container", "tcp://host:2375", "https://host", "ssh://deploy@host", "unix://relative/path", ""]) { expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: badUrl })).toThrow(); } }); - it("rejects an ssh url without a user", () => { - expect(() => createMonitorBodyValidation.parse({ ...baseDockerBody, url: "ssh://host", sshPrivateKey: PRIVATE_KEY })).toThrow(); - }); - - it("rejects an ssh url without a private key, attributing the issue to sshPrivateKey", () => { - const result = createMonitorBodyValidation.safeParse({ ...baseDockerBody, url: "ssh://deploy@host" }); - expect(result.success).toBe(false); - expect(result.error?.issues.some((issue) => issue.path.includes("sshPrivateKey"))).toBe(true); - }); - it("does not apply docker url rules to other monitor types", () => { const parsed = createMonitorBodyValidation.parse({ name: "HTTP check", type: "http", url: "https://example.com" }); - expect(parsed.sshPrivateKey).toBeUndefined(); + expect(parsed.url).toBe("https://example.com"); }); }); @@ -624,7 +642,7 @@ describe("monitorValidation — Docker host url", () => { expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "my-container" })).toThrow(); }); - it("rejects a docker edit switching to ssh without a key", () => { + it("rejects a docker edit with an ssh url", () => { expect(() => editMonitorBodyValidation.parse({ type: "docker", url: "ssh://deploy@host" })).toThrow(); }); });